├── .envrc ├── .github └── workflows │ └── release.yaml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yaml ├── LICENSE ├── Makefile ├── README.md ├── cmd └── command.go ├── go.mod ├── go.sum ├── internal ├── mpls │ ├── handler.go │ ├── mpls.go │ ├── server.go │ ├── textdocsync.go │ └── workspace.go └── previewserver │ ├── previewserver.go │ └── web │ ├── colors-dark.css │ ├── colors-light.css │ ├── index.html │ ├── styles.css │ └── ws.js ├── main.go ├── pkg ├── parser │ ├── extensions.go │ ├── extensions_cgo.go │ └── parser.go └── plantuml │ └── plantuml.go └── screenshots └── demo.gif /.envrc: -------------------------------------------------------------------------------- 1 | export CGO_ENABLED=1 2 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | build: 10 | name: build release 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Check out code 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: stable 23 | 24 | - name: Run GoReleaser 25 | uses: goreleaser/goreleaser-action@v6 26 | with: 27 | version: '~> v2' 28 | args: release --clean 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GO_RELEASER_GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | !/internal/mpls 2 | /mpls 3 | test-*.md 4 | out.html 5 | assets/* 6 | cover.out 7 | # Added by goreleaser init: 8 | dist/ 9 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: none 4 | enable: 5 | # Enabled by default -- Few false positives 6 | - errcheck 7 | - govet 8 | - ineffassign 9 | - staticcheck 10 | - unused 11 | 12 | # The following are additionally enabled 13 | - containedctx 14 | - gocritic 15 | - godot 16 | - gosec 17 | - misspell 18 | - nakedret 19 | - nlreturn # new line before return 20 | - noctx # check requests for context 21 | - paralleltest 22 | - revive 23 | - testifylint # use proper testify methods 24 | - unconvert # Remove unnecessary type conversions 25 | - unparam 26 | - whitespace 27 | - wsl 28 | settings: 29 | nakedret: 30 | max-func-lines: 5 31 | exclusions: 32 | generated: lax 33 | presets: 34 | - common-false-positives 35 | - std-error-handling 36 | - comments 37 | issues: 38 | max-issues-per-linter: 50 39 | max-same-issues: 8 40 | fix: true 41 | formatters: 42 | enable: 43 | - goimports 44 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 2 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 3 | 4 | before: 5 | hooks: 6 | - go mod tidy 7 | 8 | builds: 9 | - env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - darwin 14 | - windows 15 | goarch: 16 | - amd64 17 | - arm64 18 | ldflags: 19 | - -s -w 20 | - -X github.com/mhersson/vectorsigma/cmd.Version={{ .Tag }} 21 | - -X github.com/mhersson/vectorsigma/cmd.CommitSHA={{ .ShortCommit }} 22 | - -X github.com/mhersson/vectorsigma/cmd.BuildTime={{ .Date }} 23 | 24 | archives: 25 | - formats: tar.gz 26 | format_overrides: 27 | - goos: windows 28 | formats: zip 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # 2 | # Makefile: Markdown Preview Language Server 3 | # 4 | 5 | shell=bash 6 | 7 | VERSION = $(shell git describe --tags --always) 8 | COMMIT = $(shell git rev-parse --short HEAD) 9 | BUILDTIME = $(shell date -u '+%Y-%m-%dT%H:%M:%SZ'.1.0) 10 | 11 | 12 | # make will interpret non-option arguments in the command line as targets. 13 | # This turns them into do-nothing targets, so make won't complain: 14 | # If the first argument is "run"... 15 | ifeq (run,$(firstword $(MAKECMDGOALS))) 16 | # use the rest as arguments for "run" 17 | RUN_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) 18 | # ...and turn them into do-nothing targets 19 | $(eval $(RUN_ARGS):;@:) 20 | endif 21 | 22 | LDFLAGS="-s -w \ 23 | -X github.com/mhersson/mpls/cmd.Version=$(VERSION) \ 24 | -X github.com/mhersson/mpls/cmd.CommitSHA=$(COMMIT) \ 25 | -X github.com/mhersson/mpls/cmd.BuildTime=$(BUILDTIME) \ 26 | -X github.com/mhersson/mpls/internal/mpls.Version=$(VERSION)" 27 | 28 | all: build 29 | 30 | ##@ General 31 | 32 | # The help target prints out all targets with their descriptions organized 33 | # beneath their categories. The categories are represented by '##@' and the 34 | # target descriptions by '##'. The awk commands is responsible for reading the 35 | # entire set of makefiles included in this invocation, looking for lines of the 36 | # file as xyz: ## something, and then pretty-format the target and help. Then, 37 | # if there's a line with ##@ something, that gets pretty-printed as a category. 38 | # More info on the usage of ANSI control characters for terminal formatting: 39 | # https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters 40 | # More info on the awk command: 41 | # http://linuxcommand.org/lc3_adv_awk.php 42 | 43 | help: ## Display this help. 44 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 45 | 46 | .PHONY: version 47 | version: 48 | @echo $(VERSION) 49 | 50 | .PHONY: fmt 51 | fmt: ## Run go fmt against code. 52 | go fmt ./... 53 | 54 | vet: ## Run go vet 55 | go vet ./... 56 | 57 | build: fmt vet ## Build the binary. 58 | @go build -ldflags $(LDFLAGS) . 59 | 60 | install: ## Install the binary. 61 | @go install -ldflags $(LDFLAGS) 62 | 63 | test: ## Run tests. 64 | @go test ./... -coverprofile cover.out 65 | 66 | run: ## Run main.go with arguments. 67 | @go run -ldflags $(LDFLAGS) ./main.go $(RUN_ARGS) 68 | 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Markdown Preview Language Server 2 | 3 | [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/mhersson/mpls)](https://goreportcard.com/report/github.com/mhersson/mpls) 5 | [![GitHub release](https://img.shields.io/github/v/release/mhersson/mpls)](https://github.com/mhersson/vectorsigma/mpls) 6 | 7 | Built using [GLSP](https://github.com/tliron/glsp) and 8 | [Goldmark](https://github.com/yuin/goldmark), and heavily inspired by 9 | [mdpls](https://github.com/euclio/mdpls) 10 | 11 | ## Overview 12 | 13 | Markdown Preview Language Server (`mpls`) is a language server designed to 14 | enhance your Markdown editing experience. With live preview in the browser, 15 | `mpls` allows you to see your Markdown content rendered in real-time. Whether 16 | you're writing documentation or creating notes, `mpls` provides a seamless and 17 | interactive environment. 18 | 19 | Built with terminal editors in mind, such as (Neo)vim and Helix, which do not 20 | have built-in Markdown rendering, `mpls` bridges the gap by providing a live 21 | preview feature that works alongside these editors. Additionally, `mpls` is 22 | compatible with any editor that supports the Language Server Protocol (LSP), 23 | making it a versatile tool for Markdown editing across various platforms. For 24 | users of Visual Studio Code, there is also a dedicated extension available at 25 | [mpls-vscode-client](https://github.com/mhersson/mpls-vscode-client), 26 | 27 | ![demo](screenshots/demo.gif) 28 | 29 | ## Features 30 | 31 | - Live Preview: Instantly see your Markdown changes reflected in the browser. 32 | 33 | ### Built with Goldmark 34 | 35 | `mpls` is built using [Goldmark](https://github.com/yuin/goldmark), a Markdown 36 | parser written in Go. Goldmark is known for its extensibility and performance, 37 | making it an ideal choice for `mpls`. 38 | 39 | #### Goldmark extensions 40 | 41 | `mpls` utilizes several of Goldmark's extensions to enhance the Markdown 42 | rendering experience: 43 | 44 | **Always enabled** 45 | 46 | - Github Flavored Markdown: Goldmark's built in GFM extension ensures Table, 47 | Strikethrough, Linkify and TaskList elements are displayed correctly. 48 | - Image Rendering: The [img64](https://github.com/tenkoh/goldmark-img64) 49 | extension allows for seamless integration of images within your Markdown 50 | files. 51 | - Math Rendering: The [katex](https://github.com/FurqanSoftware/goldmark-katex) 52 | extension enables the rendering of LaTeX-style mathematical expressions using 53 | KaTeX. _Please note that the KaTeX extension requires `cgo` and will only be 54 | included if `mpls` is built with `CGO_ENABLED=1`. This option is not enabled 55 | for the prebuilt binaries._ 56 | - Metadata: The [meta](https://github.com/yuin/goldmark-meta) extension parses 57 | metadata in YAML format. 58 | - Syntax highlighting: The 59 | [highlighting](https://github.com/yuin/goldmark-highlighting) extension adds 60 | syntax-highlighting to the fenced code blocks. 61 | 62 | **Optional** 63 | 64 | - Emoji: The [emoji](https://github.com/yuin/goldmark-emoji) extension enables 65 | emoji support. 66 | - Footnotes: The 67 | [footnote](https://michelf.ca/projects/php-markdown/extra/#footnotes) 68 | extension enables footnotes. 69 | - Wikilinks rendering: The 70 | [wikilink](https://github.com/abhinav/goldmark-wikilink) extension enables 71 | parsing and rendering of [[wiki]] -style links. (_Note:_ image preview does 72 | not work for wikilinks) 73 | 74 | If you want a new Goldmark extension added to `mpls` please look 75 | [here](https://github.com/mhersson/mpls/issues/4). 76 | 77 | ### Mermaid 78 | 79 | `mpls` supports the display of diagrams and flowcharts by integrating 80 | [Mermaid.js](https://mermaid.js.org/), a powerful JavaScript library for 81 | generating diagrams from text definitions. 82 | 83 | ### PlantUML 84 | 85 | `mpls` supports [PlantUML](https://plantuml.com/), a powerful tool for creating 86 | UML diagrams from plain text descriptions. This integration allows you to easily 87 | embed PlantUML code in your markdown files. Diagrams are rendered upon saving 88 | and only if the UML code has changed. 89 | 90 | _Please note that external HTTP calls are made only when UML code is present in 91 | the markdown and has changed, as well as when a file is opened. For users 92 | concerned about security, you can host a PlantUML server locally and specify the 93 | `--plantuml-server` flag to ensure that no external calls are made._ 94 | 95 | ## Install 96 | 97 | If you already have go installed you can just run: 98 | 99 | ```bash 100 | go install github.com/mhersson/mpls@latest 101 | ``` 102 | 103 | If not, the easiest way to install `mpls` is to download one of the prebuilt 104 | release binaries. You can find the latest releases on the 105 | [Releases page](https://github.com/mhersson/mpls/releases). 106 | 107 | 1. Download the appropriate tar.gz file for your operating system. 108 | 2. Extract the contents of the tar.gz file. You can do this using the following 109 | command in your terminal: 110 | 111 | ```bash 112 | tar -xzf mpls__linux_amd64.tar.gz 113 | ``` 114 | 115 | (Replace `` with the actual version of the release.) 116 | 117 | 3. Copy the extracted binary to a directory that is in your system's PATH. For 118 | example: 119 | 120 | ```bash 121 | sudo cp mpls /usr/local/bin/ 122 | ``` 123 | 124 |
125 | Build From Source 126 | 127 | If you otherwise prefer to build manually from source, if you want the KaTeX 128 | math extension, or if no prebuilt binaries are available for your architecture, 129 | follow these steps: 130 | 131 | 1. **Clone the repository**: 132 | 133 | ```bash 134 | git clone https://github.com/mhersson/mpls.git 135 | cd mpls 136 | ``` 137 | 138 | 2. **Build the project**: 139 | 140 | You can build the project using the following command: 141 | 142 | _To include the math extension, you need to set `CGO_ENABLED=1` before 143 | running this command:_ 144 | 145 | ```bash 146 | make build 147 | ``` 148 | 149 | This command will compile the source code and create an executable. 150 | 151 | 3. **Install the executable**: 152 | 153 | You have two options to install the executable: 154 | 155 | - **Option 1: Copy the executable to your PATH**: 156 | 157 | After building, you can manually copy the executable to a directory that is 158 | in your system's PATH. For example: 159 | 160 | ```bash 161 | sudo cp mpls /usr/local/bin/ 162 | ``` 163 | 164 | - **Option 2: Use `make install` if you are using GOPATH**: 165 | 166 | If the GOPATH is in your PATH, you can run: 167 | 168 | ```bash 169 | make install 170 | ``` 171 | 172 | This will install the executable to your `$GOPATH/bin` directory. 173 | 174 |
175 | 176 | **Verify the installation**: 177 | 178 | After installation, you can verify that `mpls` is installed correctly by 179 | running: 180 | 181 | ```bash 182 | mpls --version 183 | ``` 184 | 185 | This should display the version of the `mpls` executable. 186 | 187 | ## Command-Line Options 188 | 189 | The following options can be used when starting `mpls`: 190 | 191 | | Flag | Description | 192 | | ------------------------ | --------------------------------------------------------------------- | 193 | | `--browser` | Specify web browser to use for the preview. **(1)** | 194 | | `--code-style` | Sets the style for syntax highlighting in fenced code blocks. **(2)** | 195 | | `--dark-mode` | Enable dark mode | 196 | | `--enable-emoji` | Enable emoji support | 197 | | `--enable-footnotes` | Enable footnotes | 198 | | `--enable-wikilinks` | Enable rendering of [[wiki]] -style links | 199 | | `--full-sync` | Sync the entire document for every change being made. **(3)** | 200 | | `--no-auto` | Don't open preview automatically | 201 | | `--plantuml-disable-tls` | Disable encryption on requests to the PlantUML server | 202 | | `--plantuml-server` | Specify the host for the PlantUML server | 203 | | `--plantuml-path` | Specify the base path for the PlantUML server | 204 | | `--port` | Set a fixed port for the preview server | 205 | | `--version` | Displays the mpls version. | 206 | | `--help` | Displays help information about the available options. | 207 | 208 | 1. On Linux specify executable e.g "firefox" or "google-chrome", on MacOS name 209 | of Application e.g "Safari" or "Microsoft Edge", on Windows use full path. On 210 | WSL, specify the executable as "explorer.exe" to start the default Windows 211 | browser. 212 | 2. The goldmark-highlighting extension use 213 | [Chroma](https://github.com/alecthomas/chroma) as the syntax highlighter, so 214 | all available styles in Chroma are available here. Default style is 215 | `catppuccin-mocha`. 216 | 3. Has a small impact on performance, but makes sure that commands like `reflow` 217 | in Helix, does not impact the accuracy of the preview. 218 | 219 | ## Configuration examples 220 | 221 | **✨Helix** 222 | 223 |
224 | click to expand 225 | 226 | In your `languages.toml` 227 | 228 | ```toml 229 | # Configured to run alongside marksman. 230 | [[language]] 231 | auto-format = true 232 | language-servers = ["marksman", "mpls"] 233 | name = "markdown" 234 | 235 | [language-server.mpls] 236 | command = "mpls" 237 | args = ["--dark-mode", "--enable-emoji"] 238 | # An example args entry showing how to specify flags with values: 239 | # args = ["--port", "8080", "--browser", "google-chrome"] 240 | ``` 241 | 242 |
243 | 244 | **✨Neovim 0.11+ using vim.lsp.enable** 245 | 246 |
247 | 248 | click to expand 249 | 250 | In my `init.lua` I have `vim.lsp.enable({"mpls"})` in addition to the following config. 251 | 252 | ```lua 253 | --- filename: ~/.config/nvim/lsp/mpls.lua 254 | return { 255 | cmd = { 256 | "mpls", 257 | "--dark-mode", 258 | "--enable-emoji", 259 | "--enable-footnotes", 260 | }, 261 | root_markers = { ".marksman.toml", ".git" }, 262 | filetypes = { "markdown", "makdown.mdx" }, 263 | on_attach = function(client, bufnr) 264 | vim.api.nvim_buf_create_user_command(bufnr, "MplsOpenPreview", function() 265 | local params = { 266 | command = "open-preview", 267 | } 268 | client.request("workspace/executeCommand", params, function(err, _) 269 | if err then 270 | vim.notify("Error executing command: " .. err.message, vim.log.levels.ERROR) 271 | else 272 | vim.notify("Preview opened", vim.log.levels.INFO) 273 | end 274 | end) 275 | end, { 276 | desc = "Preview markdown with mpls", 277 | }) 278 | end, 279 | } 280 | ``` 281 | 282 | The following autocmds config is optional, it makes `mpls` update the preview 283 | whenever you change focus to a buffer containing a markdown file. 284 | 285 | ```lua 286 | --- filename: ~/.config/nvim/lua/config/autocmds.lua 287 | 288 | --- MPLS Focus Handler 289 | local function create_debounced_mpls_sender(delay) 290 | delay = delay or 300 291 | local timer = nil 292 | 293 | return function() 294 | if timer then 295 | timer:close() 296 | timer = nil 297 | end 298 | 299 | ---@diagnostic disable-next-line: undefined-field 300 | timer = vim.uv.new_timer() 301 | if not timer then 302 | vim.notify("Failed to create timer for MPLS focus", vim.log.levels.ERROR) 303 | return 304 | end 305 | 306 | timer:start( 307 | delay, 308 | 0, 309 | vim.schedule_wrap(function() 310 | local bufnr = vim.api.nvim_get_current_buf() 311 | 312 | local filetype = vim.api.nvim_get_option_value("filetype", { buf = bufnr }) 313 | if filetype ~= "markdown" then 314 | return 315 | end 316 | 317 | local clients = vim.lsp.get_clients({ name = "mpls" }) 318 | 319 | if #clients == 0 then 320 | return 321 | end 322 | 323 | local client = clients[1] 324 | local params = { uri = vim.uri_from_bufnr(bufnr) } 325 | 326 | ---@diagnostic disable-next-line: param-type-mismatch 327 | client:notify("mpls/editorDidChangeFocus", params) 328 | 329 | if timer then 330 | timer:close() 331 | timer = nil 332 | end 333 | end) 334 | ) 335 | end 336 | end 337 | 338 | local send_mpls_focus = create_debounced_mpls_sender(300) 339 | 340 | local group = vim.api.nvim_create_augroup("MplsFocus", { clear = true }) 341 | vim.api.nvim_create_autocmd("BufEnter", { 342 | pattern = "*.md", 343 | callback = send_mpls_focus, 344 | group = group, 345 | desc = "Notify MPLS of buffer focus changes", 346 | } 347 | ``` 348 | 349 |
350 | 351 | **✨Doom-Emacs with lsp-mode** 352 | 353 |
354 | click to expand 355 | 356 | In your `config.el` 357 | 358 | ```elisp 359 | (after! markdown-mode 360 | ;; Auto start 361 | (add-hook 'markdown-mode-local-vars-hook #'lsp!)) 362 | 363 | (after! lsp-mode 364 | (defgroup lsp-mpls nil 365 | "Settings for the mpls language server client." 366 | :group 'lsp-mode 367 | :link '(url-link "https://github.com/mhersson/mpls")) 368 | 369 | (defun mpls-open-preview () 370 | "Open preview of current buffer" 371 | (interactive) 372 | (lsp-request 373 | "workspace/executeCommand" 374 | (list :command "open-preview"))) 375 | 376 | (defcustom lsp-mpls-server-command "mpls" 377 | "The binary (or full path to binary) which executes the server." 378 | :type 'string 379 | :group 'lsp-mpls) 380 | 381 | (lsp-register-client 382 | (make-lsp-client :new-connection (lsp-stdio-connection 383 | (lambda () 384 | (list 385 | (or (executable-find lsp-mpls-server-command) 386 | (lsp-package-path 'mpls) 387 | "mpls") 388 | "--dark-mode" 389 | "--enable-emoji" 390 | ))) 391 | :activation-fn (lsp-activate-on "markdown") 392 | :initialized-fn (lambda (workspace) 393 | (with-lsp-workspace workspace 394 | (lsp--set-configuration 395 | (lsp-configuration-section "mpls")) 396 | )) 397 | ;; Priority and add-on? are not needed, 398 | ;; but makes mpls work alongside other lsp servers like marksman 399 | :priority 1 400 | :add-on? t 401 | :server-id 'mpls)) 402 | 403 | ;; Send mpls/editorDidChangeFocus events 404 | (defvar last-focused-markdown-buffer nil 405 | "Tracks the last markdown buffer that had focus.") 406 | 407 | (defun send-markdown-focus-notification () 408 | "Send an event when focus changes to a markdown buffer." 409 | (when (and (eq major-mode 'markdown-mode) 410 | (not (eq (current-buffer) last-focused-markdown-buffer)) 411 | lsp--buffer-workspaces) 412 | (setq last-focused-markdown-buffer (current-buffer)) 413 | 414 | ;; Get the full file path and convert it to a URI 415 | (let* ((file-name (buffer-file-name)) 416 | (uri (lsp--path-to-uri file-name))) 417 | ;; Send notification 418 | (lsp-notify "mpls/editorDidChangeFocus" 419 | (list :uri uri 420 | :fileName file-name))))) 421 | 422 | (defun setup-markdown-focus-tracking () 423 | "Setup tracking for markdown buffer focus changes." 424 | (add-hook 'buffer-list-update-hook 425 | (lambda () 426 | (let ((current-window-buffer (window-buffer (selected-window)))) 427 | (when (and (eq current-window-buffer (current-buffer)) 428 | (eq major-mode 'markdown-mode) 429 | (buffer-file-name)) 430 | (send-markdown-focus-notification)))))) 431 | 432 | ;; Initialize the tracking 433 | (setup-markdown-focus-tracking)) 434 | ``` 435 | 436 |
437 | 438 | --- 439 | 440 | Thank you for reading my entire README! 🎉 If you made it this far, I hope you 441 | decide to try out `mpls` and that it works wonders for your Markdown editing 🙂 442 | If you later have some feedback or want to contribute? Issues and PRs are always 443 | appreciated! 444 | -------------------------------------------------------------------------------- /cmd/command.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "runtime/debug" 7 | 8 | "github.com/mhersson/mpls/internal/mpls" 9 | "github.com/mhersson/mpls/internal/previewserver" 10 | "github.com/mhersson/mpls/pkg/parser" 11 | "github.com/mhersson/mpls/pkg/plantuml" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | var ( 16 | noAuto bool 17 | Version = "dev" 18 | CommitSHA = "unknown" 19 | BuildTime = "unknown" 20 | ) 21 | 22 | var command = &cobra.Command{ 23 | Use: "mpls", 24 | Short: "Markdown Preview Language Server", 25 | Version: getVersionInfo(), 26 | Run: func(cmd *cobra.Command, _ []string) { 27 | cmd.Printf("mpls %s - press Ctrl+D to quit.\n", cmd.Version) 28 | previewserver.OpenBrowserOnStartup = !noAuto 29 | mpls.Run() 30 | }, 31 | } 32 | 33 | func getVersionInfo() string { 34 | if Version == "dev" { 35 | if info, ok := debug.ReadBuildInfo(); ok { 36 | for _, setting := range info.Settings { 37 | if setting.Key == "vcs.revision" { 38 | CommitSHA = setting.Value[:8] 39 | } 40 | 41 | if setting.Key == "vcs.time" { 42 | BuildTime = setting.Value 43 | } 44 | } 45 | 46 | Version = info.Main.Version 47 | if Version == "(devel)" { 48 | return Version 49 | } 50 | } 51 | } 52 | 53 | if CommitSHA == "unknown" || BuildTime == "unknown" { 54 | return Version 55 | } 56 | 57 | return fmt.Sprintf("%s (commit: %s, built at: %s)", Version, CommitSHA, BuildTime) 58 | } 59 | 60 | func Execute() { 61 | err := command.Execute() 62 | if err != nil { 63 | os.Exit(1) 64 | } 65 | } 66 | 67 | func init() { 68 | command.Flags().StringVar(&previewserver.Browser, "browser", "", "Specify the web browser to use for the preview") 69 | command.Flags().StringVar(&parser.CodeHighlightingStyle, "code-style", "catppuccin-mocha", "Higlighting style for code blocks") 70 | command.Flags().BoolVar(&previewserver.DarkMode, "dark-mode", false, "Enable dark mode") 71 | command.Flags().BoolVar(&parser.EnableEmoji, "enable-emoji", false, "Enable emoji support") 72 | command.Flags().BoolVar(&parser.EnableFootnotes, "enable-footnotes", false, "Enable footnotes") 73 | command.Flags().BoolVar(&parser.EnableWikiLinks, "enable-wikilinks", false, "Enable [[wiki]] style links") 74 | command.Flags().BoolVar(&mpls.TextDocumentUseFullSync, "full-sync", false, "Sync entire document for every change") 75 | command.Flags().BoolVar(&noAuto, "no-auto", false, "Don't open preview automatically") 76 | command.Flags().IntVar(&previewserver.FixedPort, "port", 0, "Set a fixed port for the preview server") 77 | command.Flags().StringVar(&plantuml.BasePath, "plantuml-path", "plantuml", "Specify the base path for the plantuml server") 78 | command.Flags().StringVar(&plantuml.Server, "plantuml-server", "www.plantuml.com", "Specify the host for the plantuml server") 79 | command.Flags().BoolVar(&plantuml.DisableTLS, "plantuml-disable-tls", false, "Disable encryption on requests to the plantuml server") 80 | } 81 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mhersson/mpls 2 | 3 | go 1.23.3 4 | 5 | require ( 6 | github.com/FurqanSoftware/goldmark-katex v0.0.0-20230820031700-1c400212c1e1 7 | github.com/gorilla/websocket v1.5.3 8 | github.com/mhersson/glsp v0.2.3 9 | github.com/spf13/cobra v1.9.1 10 | github.com/tenkoh/goldmark-img64 v0.1.2 11 | github.com/tliron/commonlog v0.2.19 12 | github.com/yuin/goldmark v1.7.12 13 | github.com/yuin/goldmark-emoji v1.0.6 14 | github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 15 | github.com/yuin/goldmark-meta v1.1.0 16 | go.abhg.dev/goldmark/wikilink v0.6.0 17 | ) 18 | 19 | require ( 20 | github.com/alecthomas/chroma/v2 v2.18.0 // indirect 21 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 22 | github.com/bluele/gcache v0.0.2 // indirect 23 | github.com/dlclark/regexp2 v1.11.5 // indirect 24 | github.com/gabriel-vasile/mimetype v1.4.9 // indirect 25 | github.com/iancoleman/strcase v0.3.0 // indirect 26 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 27 | github.com/lithdew/quickjs v0.0.0-20200714182134-aaa42285c9d2 // indirect 28 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 29 | github.com/mattn/go-isatty v0.0.20 // indirect 30 | github.com/muesli/termenv v0.16.0 // indirect 31 | github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb // indirect 32 | github.com/pkg/errors v0.9.1 // indirect 33 | github.com/rivo/uniseg v0.4.7 // indirect 34 | github.com/sasha-s/go-deadlock v0.3.5 // indirect 35 | github.com/segmentio/ksuid v1.0.4 // indirect 36 | github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect 37 | github.com/spf13/pflag v1.0.6 // indirect 38 | github.com/tliron/kutil v0.3.26 // indirect 39 | golang.org/x/crypto v0.38.0 // indirect 40 | golang.org/x/net v0.40.0 // indirect 41 | golang.org/x/sys v0.33.0 // indirect 42 | golang.org/x/term v0.32.0 // indirect 43 | gopkg.in/yaml.v2 v2.4.0 // indirect 44 | ) 45 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/FurqanSoftware/goldmark-katex v0.0.0-20230820031700-1c400212c1e1 h1:zm4WOvvzOeEiA47eE74RTNTi/FC5Cpw6R4fk/4hxdpc= 2 | github.com/FurqanSoftware/goldmark-katex v0.0.0-20230820031700-1c400212c1e1/go.mod h1:Z4lsscLMP+DMuc7k7AU0TsFRA2xQWTGhOp9zELvG1lQ= 3 | github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 4 | github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 5 | github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= 6 | github.com/alecthomas/chroma/v2 v2.18.0 h1:6h53Q4hW83SuF+jcsp7CVhLsMozzvQvO8HBbKQW+gn4= 7 | github.com/alecthomas/chroma/v2 v2.18.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= 8 | github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= 9 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 10 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 11 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 12 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 13 | github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw= 14 | github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0= 15 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 16 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 19 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 20 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 21 | github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 22 | github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 23 | github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 24 | github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= 25 | github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= 26 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 27 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 28 | github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 29 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 30 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 31 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 32 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 33 | github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= 34 | github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= 35 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 36 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 37 | github.com/lithdew/quickjs v0.0.0-20200714182134-aaa42285c9d2 h1:9o8F2Jlv6jetf9FKdseYhgv036iyW87vi9DoFd2O76s= 38 | github.com/lithdew/quickjs v0.0.0-20200714182134-aaa42285c9d2/go.mod h1:zkXUczDT56GViklqUXAzmvSKkGTxV2jrG/NOWqHAbT8= 39 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 40 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 41 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 42 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 43 | github.com/mhersson/glsp v0.2.3 h1:Ex/5OI9svM9YRQKIuDI69tdWV7mPImfrqDvOOY8p9zE= 44 | github.com/mhersson/glsp v0.2.3/go.mod h1:PRT72kA0Cr+Z/n2fRRqZM6Gzv0UqumM1GucvpjIEOJc= 45 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 46 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 47 | github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= 48 | github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb h1:3PrKuO92dUTMrQ9dx0YNejC6U/Si6jqKmyQ9vWjwqR4= 49 | github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= 50 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 51 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 52 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 53 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 54 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 55 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 56 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 57 | github.com/sasha-s/go-deadlock v0.3.5 h1:tNCOEEDG6tBqrNDOX35j/7hL5FcFViG6awUGROb2NsU= 58 | github.com/sasha-s/go-deadlock v0.3.5/go.mod h1:bugP6EGbdGYObIlx7pUZtWqlvo8k9H6vCBBsiChJQ5U= 59 | github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= 60 | github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= 61 | github.com/sourcegraph/jsonrpc2 v0.2.1 h1:2GtljixMQYUYCmIg7W9aF2dFmniq/mOr2T9tFRh6zSQ= 62 | github.com/sourcegraph/jsonrpc2 v0.2.1/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo= 63 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 64 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 65 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 66 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 67 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 68 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 69 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 70 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 71 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 72 | github.com/tenkoh/goldmark-img64 v0.1.2 h1:z2LX9PycENbXdAUQKwk0fyKGxuQ04wWnKHLnYOtZSVU= 73 | github.com/tenkoh/goldmark-img64 v0.1.2/go.mod h1:m3Z5ytQWSf1Lcdv2cXWXSRB7epGz5ka7kUeXTDSgDwk= 74 | github.com/tliron/commonlog v0.2.19 h1:v1mOH1TyzFLqkshR03khw7ENAZPjAyZTQBQrqN+vX9c= 75 | github.com/tliron/commonlog v0.2.19/go.mod h1:AcdhfcUqlAWukDrzTGyaPhUgYiNdZhS4dKzD/e0tjcY= 76 | github.com/tliron/kutil v0.3.26 h1:G+dicQLvzm3zdOMrrQFLBfHJXtk57fEu2kf1IFNyJxw= 77 | github.com/tliron/kutil v0.3.26/go.mod h1:1/HRVAb+fnRIRnzmhu0FPP+ZJKobrpwHStDVMuaXDzY= 78 | github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 79 | github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= 80 | github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 81 | github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= 82 | github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= 83 | github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 84 | github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 85 | github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc= 86 | github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0= 87 | go.abhg.dev/goldmark/wikilink v0.6.0 h1:SKZANgMD7GMbaU0kBKTh52Ea9k3A3Y5ZifHoEPC1fuo= 88 | go.abhg.dev/goldmark/wikilink v0.6.0/go.mod h1:Sfaovp00aAVJ5khqIeDTTgkIfZrcurmJGlbntCJUbJY= 89 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 90 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 91 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 92 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 93 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 94 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 95 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 96 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 97 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 98 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 99 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 100 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 101 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 102 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 103 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 104 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 105 | -------------------------------------------------------------------------------- /internal/mpls/handler.go: -------------------------------------------------------------------------------- 1 | package mpls 2 | 3 | import ( 4 | protocol "github.com/mhersson/glsp/protocol_mpls" 5 | ) 6 | 7 | var Handler protocol.Handler 8 | 9 | func init() { 10 | Handler.Initialize = initialize 11 | Handler.Initialized = initialized 12 | Handler.Shutdown = shutdown 13 | Handler.SetTrace = setTrace 14 | Handler.TextDocumentDidOpen = TextDocumentDidOpen 15 | Handler.TextDocumentDidChange = TextDocumentDidChange 16 | Handler.TextDocumentDidSave = TextDocumentDidSave 17 | Handler.TextDocumentDidClose = TextDocumentDidClose 18 | Handler.WorkspaceExecuteCommand = WorkspaceExecuteCommand 19 | Handler.WorkspaceDidChangeConfiguration = WorkspaceDidChangeConfiguration 20 | Handler.MplsEditorDidChangeFocus = EditorDidChangeFocus 21 | } 22 | -------------------------------------------------------------------------------- /internal/mpls/mpls.go: -------------------------------------------------------------------------------- 1 | package mpls 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/mhersson/glsp" 7 | protocol316 "github.com/mhersson/glsp/protocol_3_16" 8 | protocol "github.com/mhersson/glsp/protocol_mpls" 9 | "github.com/mhersson/mpls/internal/previewserver" 10 | "github.com/mhersson/mpls/pkg/parser" 11 | "github.com/mhersson/mpls/pkg/plantuml" 12 | ) 13 | 14 | func EditorDidChangeFocus(ctx *glsp.Context, params *protocol.EditorDidChangeFocusParams) error { 15 | var err error 16 | 17 | plantumls = []plantuml.Plantuml{} 18 | 19 | if currentURI == params.URI { 20 | return nil 21 | } 22 | 23 | _ = protocol316.Trace(ctx, protocol316.MessageTypeInfo, log("MplsEditorDidChangedFocus: "+params.URI)) 24 | 25 | if !previewserver.OpenBrowserOnStartup && content == "" { 26 | return nil 27 | } 28 | 29 | content, err = loadDocument(params.URI) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | filename = filepath.Base(params.URI) 35 | currentURI = params.URI 36 | 37 | html, meta := parser.HTML(content, currentURI) 38 | 39 | html, err = insertPlantumlDiagram(html, true) 40 | if err != nil { 41 | _ = protocol316.Trace(ctx, protocol316.MessageTypeWarning, log("MplsEditorDidChangeFocus - plantuml: "+err.Error())) 42 | } 43 | 44 | previewServer.Update(filename, html, meta) 45 | 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /internal/mpls/server.go: -------------------------------------------------------------------------------- 1 | package mpls 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/mhersson/glsp" 7 | protocol316 "github.com/mhersson/glsp/protocol_3_16" 8 | protocol "github.com/mhersson/glsp/protocol_mpls" 9 | serverPkg "github.com/mhersson/glsp/server" 10 | "github.com/mhersson/mpls/internal/previewserver" 11 | 12 | // Must include a backend implementation 13 | // See CommonLog for other options: https://github.com/tliron/commonlog 14 | _ "github.com/tliron/commonlog/simple" 15 | ) 16 | 17 | const lsName = "Markdown Preview Language Server" 18 | 19 | var ( 20 | TextDocumentUseFullSync bool 21 | Version string 22 | ) 23 | 24 | func log(message string) string { 25 | return time.Now().Local().Format("2006-01-02 15:04:05") + " " + message 26 | } 27 | 28 | func Run() { 29 | previewServer = previewserver.New() 30 | go previewServer.Start() 31 | 32 | lspServer := serverPkg.NewServer(&Handler, lsName, false) 33 | 34 | _ = lspServer.RunStdio() 35 | } 36 | 37 | func initialize(context *glsp.Context, _ *protocol.InitializeParams) (any, error) { 38 | protocol316.SetTraceValue("message") 39 | _ = protocol316.Trace(context, protocol316.MessageTypeInfo, log("Initializing "+lsName)) 40 | 41 | capabilities := Handler.CreateServerCapabilities() 42 | if TextDocumentUseFullSync { 43 | capabilities.TextDocumentSync = protocol316.TextDocumentSyncKindFull 44 | } 45 | 46 | capabilities.ExecuteCommandProvider.Commands = []string{"open-preview"} 47 | 48 | return protocol.InitializeResult{ 49 | Capabilities: capabilities, 50 | ServerInfo: &protocol316.InitializeResultServerInfo{ 51 | Name: lsName, 52 | Version: &Version, 53 | }, 54 | }, nil 55 | } 56 | 57 | func initialized(_ *glsp.Context, _ *protocol316.InitializedParams) error { 58 | return nil 59 | } 60 | 61 | func setTrace(_ *glsp.Context, params *protocol316.SetTraceParams) error { 62 | protocol316.SetTraceValue(params.Value) 63 | 64 | return nil 65 | } 66 | 67 | func shutdown(_ *glsp.Context) error { 68 | previewServer.Stop() 69 | protocol316.SetTraceValue(protocol316.TraceValueOff) 70 | 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /internal/mpls/textdocsync.go: -------------------------------------------------------------------------------- 1 | package mpls 2 | 3 | import ( 4 | "html" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "time" 9 | 10 | "github.com/mhersson/glsp" 11 | protocol "github.com/mhersson/glsp/protocol_3_16" 12 | "github.com/mhersson/mpls/internal/previewserver" 13 | "github.com/mhersson/mpls/pkg/parser" 14 | "github.com/mhersson/mpls/pkg/plantuml" 15 | ) 16 | 17 | var ( 18 | content string 19 | currentURI string 20 | filename string 21 | previewServer *previewserver.Server 22 | plantumls []plantuml.Plantuml 23 | ) 24 | 25 | func TextDocumentDidOpen(ctx *glsp.Context, params *protocol.DidOpenTextDocumentParams) error { 26 | var err error 27 | 28 | currentURI = params.TextDocument.URI 29 | filename = filepath.Base(currentURI) 30 | plantumls = []plantuml.Plantuml{} 31 | 32 | _ = protocol.Trace(ctx, protocol.MessageTypeInfo, log("TextDocumentDidOpen: "+params.TextDocument.URI)) 33 | 34 | if !previewserver.OpenBrowserOnStartup && content == "" { 35 | return nil 36 | } 37 | 38 | doc := params.TextDocument 39 | 40 | content = doc.Text 41 | 42 | // Give the browser time to connect 43 | if err = previewserver.WaitForClients(10 * time.Second); err != nil { 44 | return err 45 | } 46 | 47 | html, meta := parser.HTML(content, currentURI) 48 | 49 | html, err = insertPlantumlDiagram(html, true) 50 | if err != nil { 51 | _ = protocol.Trace(ctx, protocol.MessageTypeWarning, log("TextDocumentDidOpen - plantuml: "+err.Error())) 52 | } 53 | 54 | previewServer.Update(filename, html, meta) 55 | 56 | return nil 57 | } 58 | 59 | func TextDocumentDidChange(ctx *glsp.Context, params *protocol.DidChangeTextDocumentParams) error { 60 | var err error 61 | 62 | switchedDocument := false 63 | 64 | for _, change := range params.ContentChanges { 65 | if c, ok := change.(protocol.TextDocumentContentChangeEvent); ok { 66 | if params.TextDocument.URI != currentURI { 67 | _ = protocol.Trace(ctx, protocol.MessageTypeInfo, 68 | log("TextDocumentUriDidChange - switching document: "+params.TextDocument.URI)) 69 | 70 | content, err = loadDocument(params.TextDocument.URI) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | currentURI = params.TextDocument.URI 76 | filename = filepath.Base(currentURI) 77 | 78 | switchedDocument = true 79 | } 80 | 81 | startIndex, endIndex := c.Range.IndexesIn(content) 82 | content = content[:startIndex] + c.Text + content[endIndex:] 83 | 84 | html, meta := parser.HTML(content, currentURI) 85 | 86 | html, err = insertPlantumlDiagram(html, switchedDocument) 87 | if err != nil { 88 | _ = protocol.Trace(ctx, protocol.MessageTypeWarning, log("TextDocumentDidChange - plantuml: "+err.Error())) 89 | } 90 | 91 | previewServer.Update(filename, html, meta) 92 | } else if c, ok := change.(protocol.TextDocumentContentChangeEventWhole); ok { 93 | html, meta := parser.HTML(c.Text, currentURI) 94 | 95 | html, err = insertPlantumlDiagram(html, false) 96 | if err != nil { 97 | _ = protocol.Trace(ctx, protocol.MessageTypeWarning, log("TextDocumentDidChange - plantuml: "+err.Error())) 98 | } 99 | 100 | previewServer.Update(filename, html, meta) 101 | } 102 | } 103 | 104 | return nil 105 | } 106 | 107 | func TextDocumentDidSave(ctx *glsp.Context, params *protocol.DidSaveTextDocumentParams) error { 108 | var err error 109 | 110 | content, err = loadDocument(params.TextDocument.URI) 111 | if err != nil { 112 | return err 113 | } 114 | 115 | html, meta := parser.HTML(content, currentURI) 116 | 117 | html, err = insertPlantumlDiagram(html, true) 118 | if err != nil { 119 | _ = protocol.Trace(ctx, protocol.MessageTypeWarning, log("TextDocumentDidOpen - plantuml: "+err.Error())) 120 | } 121 | 122 | previewServer.Update(filename, html, meta) 123 | 124 | return nil 125 | } 126 | 127 | func TextDocumentDidClose(_ *glsp.Context, _ *protocol.DidCloseTextDocumentParams) error { 128 | return nil 129 | } 130 | 131 | func loadDocument(uri string) (string, error) { 132 | f := parser.NormalizePath(uri) 133 | 134 | c, err := os.ReadFile(f) 135 | if err != nil { 136 | return "", err 137 | } 138 | 139 | return string(c), nil 140 | } 141 | 142 | func insertPlantumlDiagram(data string, generate bool) (string, error) { 143 | const startDelimiter = `
`
144 | 
145 | 	var builder strings.Builder
146 | 
147 | 	var err error
148 | 
149 | 	numDiagrams := 0
150 | 	start := 0
151 | 
152 | 	for {
153 | 		s, e := extractPlantUMLSection(data[start:])
154 | 		if s == -1 || e == -1 {
155 | 			builder.WriteString(data[start:])
156 | 
157 | 			break
158 | 		}
159 | 
160 | 		builder.WriteString(data[start : start+s])
161 | 
162 | 		htmlEncodedUml := data[start+s+len(startDelimiter) : start+e]
163 | 		uml := html.UnescapeString(htmlEncodedUml)
164 | 
165 | 		p := plantuml.Plantuml{}
166 | 		p.EncodedUML = plantuml.Encode(uml)
167 | 
168 | 		generated := false
169 | 
170 | 		for _, enc := range plantumls {
171 | 			if p.EncodedUML == enc.EncodedUML {
172 | 				p.Diagram = enc.Diagram
173 | 				generated = true
174 | 
175 | 				break
176 | 			}
177 | 		}
178 | 
179 | 		if !generated && generate {
180 | 			p.Diagram, err = plantuml.GetDiagram(p.EncodedUML)
181 | 			if err != nil {
182 | 				return data, err
183 | 			}
184 | 		}
185 | 
186 | 		numDiagrams++
187 | 
188 | 		if generate {
189 | 			if len(plantumls) < numDiagrams {
190 | 				plantumls = append(plantumls, p)
191 | 			} else {
192 | 				plantumls[numDiagrams-1] = p
193 | 			}
194 | 
195 | 			builder.WriteString(p.Diagram)
196 | 		} else if len(plantumls) >= numDiagrams {
197 | 			// Use existing until we save and generate a new one
198 | 			builder.WriteString(plantumls[numDiagrams-1].Diagram)
199 | 		}
200 | 
201 | 		start += e + 13
202 | 	}
203 | 
204 | 	return builder.String(), nil
205 | }
206 | 
207 | func extractPlantUMLSection(text string) (int, int) {
208 | 	const startDelimiter = `
`
209 | 
210 | 	const endDelimiter = "
" 211 | 212 | startIndex := strings.Index(text, startDelimiter) 213 | if startIndex == -1 { 214 | return -1, -1 215 | } 216 | 217 | endIndex := strings.Index(text[startIndex+len(startDelimiter):], endDelimiter) 218 | if endIndex == -1 { 219 | return startIndex, -1 220 | } 221 | 222 | // Calculate the actual end index in the original text 223 | endIndex += startIndex + len(startDelimiter) 224 | 225 | return startIndex, endIndex 226 | } 227 | -------------------------------------------------------------------------------- /internal/mpls/workspace.go: -------------------------------------------------------------------------------- 1 | package mpls 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/mhersson/glsp" 9 | protocol "github.com/mhersson/glsp/protocol_3_16" 10 | "github.com/mhersson/mpls/internal/previewserver" 11 | "github.com/mhersson/mpls/pkg/parser" 12 | ) 13 | 14 | func WorkspaceExecuteCommand(ctx *glsp.Context, param *protocol.ExecuteCommandParams) (any, error) { 15 | switch param.Command { 16 | case "open-preview": 17 | _ = protocol.Trace(ctx, protocol.MessageTypeInfo, 18 | log("WorkspaceExecuteCommand - Open preview: "+currentURI)) 19 | 20 | err := previewserver.Openbrowser(fmt.Sprintf("http://localhost:%d", previewServer.Port), previewserver.Browser) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | if err := previewserver.WaitForClients(10 * time.Second); err != nil { 26 | return nil, err 27 | } 28 | 29 | content, err = loadDocument(currentURI) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | html, meta := parser.HTML(content, currentURI) 35 | 36 | html, err = insertPlantumlDiagram(html, true) 37 | if err != nil { 38 | _ = protocol.Trace(ctx, protocol.MessageTypeWarning, log("WorkspaceExcueCommand - Open preview: "+err.Error())) 39 | } 40 | 41 | previewServer.Update(filename, html, meta) 42 | default: 43 | return nil, errors.New("unknow command") 44 | } 45 | 46 | return nil, nil 47 | } 48 | 49 | func WorkspaceDidChangeConfiguration(_ *glsp.Context, _ *protocol.DidChangeConfigurationParams) error { 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/previewserver/previewserver.go: -------------------------------------------------------------------------------- 1 | package previewserver 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "encoding/json" 7 | "fmt" 8 | "math/rand" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "os/exec" 13 | "os/signal" 14 | "runtime" 15 | "sort" 16 | "strings" 17 | "sync" 18 | "time" 19 | 20 | "github.com/gorilla/websocket" 21 | ) 22 | 23 | var ( 24 | Browser string 25 | DarkMode bool 26 | FixedPort int 27 | OpenBrowserOnStartup bool 28 | 29 | //go:embed web/index.html 30 | indexHTML string 31 | //go:embed web/styles.css 32 | stylesCSS string 33 | //go:embed web/colors-dark.css 34 | colorsDarkCSS string 35 | //go:embed web/colors-light.css 36 | colorsLightCSS string 37 | //go:embed web/ws.js 38 | websocketJS string 39 | 40 | broadcast = make(chan []byte) 41 | clients = make(map[*websocket.Conn]bool) 42 | clientsMutex sync.Mutex 43 | stopChan = make(chan os.Signal, 1) 44 | ) 45 | 46 | type Server struct { 47 | Server *http.Server 48 | InitialContent string 49 | Port int 50 | } 51 | 52 | func logTime() string { 53 | return time.Now().Local().Format("2006-01-02 15:04:05") 54 | } 55 | 56 | func WaitForClients(timeout time.Duration) error { 57 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 58 | defer cancel() 59 | 60 | ticker := time.NewTicker(100 * time.Millisecond) 61 | defer ticker.Stop() 62 | 63 | for { 64 | select { 65 | case <-ctx.Done(): 66 | return fmt.Errorf("timeout waiting for clients to connect") 67 | case <-ticker.C: 68 | if len(clients) > 0 { 69 | return nil 70 | } 71 | } 72 | } 73 | } 74 | 75 | func New() *Server { 76 | port := rand.Intn(65535-10000) + 10000 //nolint:gosec 77 | if FixedPort > 0 { 78 | port = FixedPort 79 | } 80 | 81 | theme := "colors-light.css" 82 | mermaidTheme := "default" 83 | 84 | if DarkMode { 85 | theme = "colors-dark.css" 86 | mermaidTheme = "dark" 87 | } 88 | 89 | indexHTML = fmt.Sprintf(indexHTML, theme, mermaidTheme) 90 | 91 | srv := &http.Server{ 92 | Addr: fmt.Sprintf(":%d", port), 93 | ReadTimeout: time.Second * 5, 94 | } 95 | 96 | return &Server{ 97 | Server: srv, 98 | InitialContent: indexHTML, 99 | Port: port, 100 | } 101 | } 102 | 103 | func (s *Server) Start() { 104 | http.HandleFunc("/", handleResponse("text/html", s.InitialContent)) 105 | http.HandleFunc("/styles.css", handleResponse("text/css", stylesCSS)) 106 | http.HandleFunc("/colors-light.css", handleResponse("text/css", colorsLightCSS)) 107 | http.HandleFunc("/colors-dark.css", handleResponse("text/css", colorsDarkCSS)) 108 | http.HandleFunc("/ws.js", handleResponse("application/javascript", fmt.Sprintf(websocketJS, s.Port))) 109 | 110 | http.HandleFunc("/ws", handleWebSocket) 111 | 112 | signal.Notify(stopChan, os.Interrupt) 113 | 114 | go handleMessages() 115 | 116 | go func() { 117 | if err := s.Server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 118 | fmt.Printf("%s error starting server: %s\n", logTime(), err) 119 | } 120 | }() 121 | 122 | if OpenBrowserOnStartup { 123 | err := Openbrowser(fmt.Sprintf("http://localhost:%d", s.Port), Browser) 124 | if err != nil { 125 | fmt.Fprintf(os.Stderr, "%s error opening browser: %v\n", logTime(), err) 126 | } 127 | } 128 | 129 | // Wait for interrupt signal 130 | <-stopChan 131 | s.Stop() 132 | } 133 | 134 | // Update updates the current HTML content. 135 | func (s *Server) Update(filename, newContent string, meta map[string]any) { 136 | u := url.URL{Scheme: "ws", Host: s.Server.Addr, Path: "/ws"} 137 | 138 | conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil) 139 | if err != nil { 140 | fmt.Fprintf(os.Stderr, "%s error connecting to server: %v\n", logTime(), err) 141 | 142 | return 143 | } 144 | 145 | defer conn.Close() 146 | 147 | type Event struct { 148 | HTML string 149 | Title string 150 | Meta string 151 | } 152 | 153 | t := strings.TrimSuffix(filename, ".md") 154 | m := convertMetaToHTMLTable(meta) 155 | 156 | e := Event{HTML: newContent, Title: t, Meta: m} 157 | 158 | eventJSON, err := json.Marshal(e) 159 | if err != nil { 160 | fmt.Fprintf(os.Stderr, "Error marshaling event to JSON: %v\n", err) 161 | 162 | return 163 | } 164 | 165 | // Send a message to the server 166 | err = conn.WriteMessage(websocket.TextMessage, eventJSON) 167 | if err != nil { 168 | fmt.Fprintf(os.Stderr, "%s error sending message: %v\n", logTime(), err) 169 | 170 | return 171 | } 172 | } 173 | 174 | // Stop gracefully shuts down the server. 175 | func (s *Server) Stop() { 176 | // Create a context with a timeout for the shutdown 177 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 178 | defer cancel() 179 | 180 | // Attempt to gracefully shut down the server 181 | if err := s.Server.Shutdown(ctx); err != nil { 182 | fmt.Fprintf(os.Stderr, "%s error shutting down server: %v\n", logTime(), err) 183 | } 184 | } 185 | 186 | func handleResponse(contentType, response string) http.HandlerFunc { 187 | return func(w http.ResponseWriter, _ *http.Request) { 188 | w.Header().Set("Content-Type", contentType) 189 | fmt.Fprint(w, response) 190 | } 191 | } 192 | 193 | func handleWebSocket(w http.ResponseWriter, r *http.Request) { 194 | wsupgrader := websocket.Upgrader{ 195 | CheckOrigin: func(_ *http.Request) bool { 196 | return true // allow all origins 197 | }, 198 | } 199 | 200 | conn, err := wsupgrader.Upgrade(w, r, nil) 201 | if err != nil { 202 | http.Error(w, "Could not open websocket connection", http.StatusBadRequest) 203 | fmt.Fprintf(os.Stderr, "%s error could not open websocket connection: %v\n", logTime(), err) 204 | 205 | return 206 | } 207 | 208 | defer func() { 209 | conn.Close() 210 | clientsMutex.Lock() 211 | delete(clients, conn) 212 | clientsMutex.Unlock() 213 | }() 214 | 215 | clientsMutex.Lock() 216 | clients[conn] = true 217 | clientsMutex.Unlock() 218 | 219 | for { 220 | _, msg, err := conn.ReadMessage() 221 | if err != nil { 222 | if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { 223 | fmt.Fprintf(os.Stderr, "%s error while reading message: %v\n", logTime(), err) 224 | } 225 | 226 | break 227 | } 228 | broadcast <- msg 229 | } 230 | } 231 | 232 | func handleMessages() { 233 | for { 234 | msg := <-broadcast 235 | 236 | clientsMutex.Lock() 237 | for client := range clients { 238 | err := client.WriteMessage(websocket.TextMessage, msg) 239 | if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { 240 | fmt.Fprintf(os.Stderr, "%s error while writing message: %v\n", logTime(), err) 241 | client.Close() 242 | delete(clients, client) 243 | } 244 | } 245 | clientsMutex.Unlock() 246 | } 247 | } 248 | 249 | func Openbrowser(url, browser string) error { 250 | var err error 251 | 252 | switch runtime.GOOS { 253 | case "linux": 254 | browserCommand := "xdg-open" 255 | if browser != "" { 256 | browserCommand = browser 257 | } 258 | 259 | err = exec.Command(browserCommand, url).Start() 260 | case "windows": 261 | if browser != "" { 262 | err = exec.Command(browser, url).Start() 263 | } else { 264 | err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() 265 | } 266 | case "darwin": 267 | openArgs := []string{"-g", url} 268 | if browser != "" { 269 | openArgs = append(openArgs[:1], "-a", browser, url) 270 | } 271 | 272 | err = exec.Command("open", openArgs...).Start() 273 | } 274 | 275 | if err != nil { 276 | return err 277 | } 278 | 279 | return nil 280 | } 281 | 282 | func convertMetaToHTMLTable(meta map[string]any) string { 283 | if len(meta) == 0 { 284 | return "" 285 | } 286 | 287 | keys := make([]string, 0, len(meta)) 288 | for k := range meta { 289 | keys = append(keys, k) 290 | } 291 | 292 | sort.Strings(keys) 293 | 294 | html := "" 295 | html += "" 296 | 297 | for _, k := range keys { 298 | html += fmt.Sprintf("", k, meta[k]) 299 | } 300 | 301 | html += "
Meta
%s%v
" 302 | 303 | return html 304 | } 305 | -------------------------------------------------------------------------------- /internal/previewserver/web/colors-dark.css: -------------------------------------------------------------------------------- 1 | /* Dark colors CSS */ 2 | :root { 3 | --text-color: #c9d1d9; 4 | --background-color: #0d1117; 5 | --border-color: #30363d; 6 | --code-background-color: #21262d; 7 | --table-header-background: #161b22; 8 | --even-row-background: #21262d; 9 | --blockquote-color: #a0aec0; 10 | --blockquote-background: #161b22; 11 | --blockquote-border-color: #30363d; 12 | --link-color: #58a6ff; 13 | --link-visited-color: #d2a8ff; 14 | --link-hover-color: #1f6feb; 15 | --modal-background-color: rgba(0, 0, 0, 0.95); 16 | --modal-close-color: #c9d1d9; 17 | } 18 | -------------------------------------------------------------------------------- /internal/previewserver/web/colors-light.css: -------------------------------------------------------------------------------- 1 | /* Light colors CSS */ 2 | :root { 3 | --text-color: #24292e; 4 | --background-color: #ffffff; 5 | --border-color: #e1e4e8; 6 | --code-background-color: #f6f8fa; 7 | --table-header-background: #f6f8fa; 8 | --even-row-background: #f9f9f9; 9 | --blockquote-color: #6a737d; 10 | --blockquote-background: #f6f8fa; 11 | --blockquote-border-color: #d1d5da; 12 | --link-color: #0366d6; 13 | --link-visited-color: #6f42c1; 14 | --link-hover-color: #0056b3; 15 | --modal-background-color: rgba(249, 249, 249, 0.95); 16 | --modal-close-color: #24292e; 17 | } 18 | -------------------------------------------------------------------------------- /internal/previewserver/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 14 | 15 | 16 | 17 |
18 |
19 | 20 | 32 |
33 |
34 |
35 |
36 |
37 | × 38 | 39 |
40 |
41 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /internal/previewserver/web/styles.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: sans-serif; 3 | line-height: 1.5; 4 | color: var(--text-color); 5 | background-color: var(--background-color); 6 | } 7 | 8 | h1::after, 9 | h2::after, 10 | h3::after, 11 | h4::after, 12 | h5::after, 13 | h6::after { 14 | content: " "; 15 | display: block; 16 | border: 1px solid var(--border-color); 17 | } 18 | 19 | body { 20 | max-width: 787px; 21 | margin: auto; 22 | } 23 | 24 | #header { 25 | display: flex; 26 | align-items: flex-start; 27 | } 28 | 29 | .preview-content { 30 | min-height: 100%; 31 | } 32 | 33 | .preview-padding { 34 | /* Create extra space at the bottom so document can scroll up */ 35 | height: 20vh; 36 | } 37 | 38 | #checkbox-container { 39 | margin-left: auto; 40 | display: flex; 41 | flex-direction: column; 42 | } 43 | 44 | #checkbox-container label { 45 | margin-bottom: 5px; 46 | } 47 | 48 | img { 49 | max-width: 100%; 50 | } 51 | 52 | #contentModal { 53 | display: none; 54 | position: fixed; 55 | top: 0; 56 | left: 0; 57 | width: 100%; 58 | height: 100%; 59 | background-color: var(--modal-background-color); 60 | z-index: 1000; 61 | justify-content: center; 62 | align-items: center; 63 | } 64 | 65 | #closeModal { 66 | color: var(--modal-close-color); 67 | position: absolute; 68 | top: 20px; 69 | right: 30px; 70 | font-size: 30px; 71 | cursor: pointer; 72 | } 73 | 74 | #imageContent { 75 | max-width: 95%; 76 | max-height: 95%; 77 | } 78 | 79 | #mermaidContent { 80 | width: 95vw; 81 | height: 95vh; 82 | } 83 | 84 | .mermaid-modal-svg { 85 | max-width: 100% !important; 86 | max-height: 100% !important; 87 | object-fit: contain !important; 88 | } 89 | 90 | pre { 91 | padding: 10px; 92 | max-width: 100%; 93 | overflow-x: auto; 94 | } 95 | 96 | code { 97 | background-color: var(--code-background-color); 98 | } 99 | 100 | code.language-mermaid { 101 | background-color: var(--background-color); 102 | } 103 | 104 | table, 105 | th, 106 | td { 107 | border: 1px solid; 108 | border-collapse: collapse; 109 | padding: 5px; 110 | } 111 | 112 | th { 113 | padding: 10px; 114 | background-color: var(--table-header-background); 115 | } 116 | 117 | tr:nth-child(even) { 118 | background-color: var(--even-row-background); 119 | } 120 | 121 | blockquote { 122 | color: var(--blockquote-color); 123 | background-color: var(--blockquote-background); 124 | border-left: 5px solid var(--blockquote-border-color); 125 | margin: 0px; 126 | padding: 0.5em 10px; 127 | } 128 | 129 | a { 130 | color: var(--link-color); 131 | text-decoration: none; 132 | } 133 | 134 | a:visited { 135 | color: var(--link-visited-color); 136 | } 137 | 138 | a:hover { 139 | color: var(--link-hover-color); 140 | text-decoration: underline; 141 | } 142 | -------------------------------------------------------------------------------- /internal/previewserver/web/ws.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", () => { 2 | const ws = new WebSocket("ws://localhost:%d/ws"); 3 | 4 | const DEBOUNCE_DELAY = 1000; 5 | const MERMAID_RENDER_DELAY = 100; 6 | const SCROLL_OFFSET = 150; 7 | const SCROLL_RETRY_DELAY = 50; 8 | const MAX_SCROLL_RETRIES = 10; 9 | 10 | let debounceTimeout; 11 | let isReloading = false; 12 | let lastScrollTarget = null; 13 | 14 | // Utility functions 15 | function debounce(func, delay) { 16 | return function (...args) { 17 | clearTimeout(debounceTimeout); 18 | debounceTimeout = setTimeout(() => func.apply(this, args), delay); 19 | }; 20 | } 21 | 22 | function $(id) { 23 | return document.getElementById(id); 24 | } 25 | 26 | function $$(selector) { 27 | return document.querySelectorAll(selector); 28 | } 29 | 30 | // Content management 31 | const saveContentToLocalStorage = debounce((renderedHtml) => { 32 | try { 33 | localStorage.setItem("savedContent", renderedHtml); 34 | } catch (error) { 35 | console.warn("Failed to save to localStorage:", error); 36 | } 37 | }, DEBOUNCE_DELAY); 38 | 39 | function updateContent(renderedHtml) { 40 | const contentElement = $("content"); 41 | if (contentElement) { 42 | contentElement.innerHTML = renderedHtml; 43 | } 44 | } 45 | 46 | function loadSavedContent() { 47 | try { 48 | const savedContent = localStorage.getItem("savedContent"); 49 | if (savedContent) { 50 | updateContent(savedContent); 51 | // Render mermaid and initialize modals after loading saved content 52 | renderMermaidAndScroll(); 53 | } 54 | } catch (error) { 55 | console.warn("Failed to load from localStorage:", error); 56 | } 57 | } 58 | 59 | // Modal functionality 60 | function createModalHandler() { 61 | const modal = $("contentModal"); 62 | const closeModal = $("closeModal"); 63 | const mermaidContent = $("mermaidContent"); 64 | const imageContent = $("imageContent"); 65 | 66 | if (!modal || !closeModal || !mermaidContent || !imageContent) { 67 | console.warn("Modal elements not found"); 68 | return { showModal: () => {}, initializeModal: () => {} }; 69 | } 70 | 71 | function showModal(type, content) { 72 | // Reset modal content 73 | mermaidContent.style.display = "none"; 74 | imageContent.style.display = "none"; 75 | mermaidContent.innerHTML = ""; 76 | imageContent.src = ""; 77 | 78 | if (type === "image" && content.src) { 79 | imageContent.src = content.src; 80 | imageContent.style.display = "flex"; 81 | } else if (type === "mermaid") { 82 | const svgClone = content.cloneNode(true); 83 | svgClone.classList.add("mermaid-modal-svg"); 84 | mermaidContent.appendChild(svgClone); 85 | mermaidContent.style.display = "flex"; 86 | } 87 | 88 | modal.style.display = "flex"; 89 | } 90 | 91 | function closeModalHandler() { 92 | modal.style.display = "none"; 93 | // Clean up modal content 94 | mermaidContent.innerHTML = ""; 95 | imageContent.src = ""; 96 | } 97 | 98 | // Event listeners 99 | closeModal.addEventListener("click", closeModalHandler); 100 | modal.addEventListener("click", (event) => { 101 | if (event.target === modal) { 102 | closeModalHandler(); 103 | } 104 | }); 105 | 106 | // Keyboard navigation 107 | document.addEventListener("keydown", (event) => { 108 | if (event.key === "Escape" && modal.style.display === "flex") { 109 | closeModalHandler(); 110 | } 111 | }); 112 | 113 | function initializeModal() { 114 | // Setup image modals 115 | $$("img").forEach((img) => { 116 | // Skip if already has handler 117 | if (img._modalHandlerAttached) return; 118 | 119 | img._modalHandler = () => showModal("image", img); 120 | img.addEventListener("click", img._modalHandler); 121 | img._modalHandlerAttached = true; 122 | img.style.cursor = "pointer"; 123 | }); 124 | 125 | // Setup Mermaid modals 126 | $$(".language-mermaid").forEach((div) => { 127 | const svg = div.querySelector("svg"); 128 | if (svg && !div._modalHandlerAttached) { 129 | div._modalHandler = () => showModal("mermaid", svg); 130 | div.addEventListener("click", div._modalHandler); 131 | div._modalHandlerAttached = true; 132 | div.style.cursor = "pointer"; 133 | } 134 | }); 135 | } 136 | 137 | return { showModal, initializeModal }; 138 | } 139 | 140 | // Mermaid rendering with measurement 141 | async function renderMermaid() { 142 | const mermaidElements = $$(".language-mermaid"); 143 | if (mermaidElements.length === 0 || !window.mermaid) { 144 | return Promise.resolve(); 145 | } 146 | 147 | // Store pre-render scroll position 148 | const scrollBefore = window.scrollY; 149 | 150 | try { 151 | // Mark elements as rendering 152 | mermaidElements.forEach((el) => { 153 | el.setAttribute("data-rendering", "true"); 154 | }); 155 | 156 | await window.mermaid.run({ 157 | querySelector: ".language-mermaid", 158 | }); 159 | 160 | // Remove rendering markers 161 | mermaidElements.forEach((el) => { 162 | el.removeAttribute("data-rendering"); 163 | }); 164 | 165 | // Return scroll adjustment needed 166 | return window.scrollY - scrollBefore; 167 | } catch (error) { 168 | console.error("Mermaid rendering failed:", error); 169 | mermaidElements.forEach((el) => { 170 | el.removeAttribute("data-rendering"); 171 | }); 172 | return 0; 173 | } 174 | } 175 | 176 | // Improved scroll functionality 177 | function scrollToEdit(retryCount = 0) { 178 | const disableScrolling = $("disable-scrolling"); 179 | if (disableScrolling?.checked) { 180 | return; 181 | } 182 | 183 | const targetElement = $("mpls-scroll-anchor"); 184 | if (!targetElement) { 185 | lastScrollTarget = null; 186 | return; 187 | } 188 | 189 | // Check if any mermaid diagrams are still rendering 190 | const renderingElements = $$('[data-rendering="true"]'); 191 | if (renderingElements.length > 0 && retryCount < MAX_SCROLL_RETRIES) { 192 | // Retry after a short delay 193 | setTimeout(() => scrollToEdit(retryCount + 1), SCROLL_RETRY_DELAY); 194 | return; 195 | } 196 | 197 | // Store target for future reference 198 | lastScrollTarget = targetElement; 199 | 200 | const elementRect = targetElement.getBoundingClientRect(); 201 | const elementTop = elementRect.top + window.scrollY; 202 | const targetScrollPosition = elementTop - SCROLL_OFFSET; 203 | 204 | // Only scroll if we're not already at the target position 205 | if (Math.abs(window.scrollY - targetScrollPosition) > 5) { 206 | window.scrollTo({ 207 | top: targetScrollPosition, 208 | behavior: "smooth", 209 | }); 210 | } 211 | } 212 | 213 | // Combined render and scroll function 214 | async function renderMermaidAndScroll() { 215 | // First render mermaid 216 | await renderMermaid(); 217 | 218 | // Then initialize modals 219 | modalHandler.initializeModal(); 220 | 221 | // Finally scroll to edit position 222 | // Use setTimeout to ensure DOM has settled 223 | setTimeout(() => { 224 | scrollToEdit(); 225 | }, MERMAID_RENDER_DELAY); 226 | } 227 | 228 | // WebSocket event handlers 229 | async function handleWebSocketMessage(event) { 230 | try { 231 | const response = JSON.parse(event.data); 232 | const { HTML: renderedHtml, Title: responseTitle, Meta: meta } = response; 233 | 234 | const title = `mpls - ${responseTitle}`; 235 | const pin = $("pin"); 236 | 237 | // Check if preview is pinned 238 | if (pin?.checked && title !== document.title) { 239 | console.log("Preview is pinned - ignoring event"); 240 | return; 241 | } 242 | 243 | // Update title if changed 244 | if (title !== document.title) { 245 | const headerSummary = $("header-summary"); 246 | if (headerSummary) { 247 | headerSummary.innerText = responseTitle; 248 | } 249 | document.title = title; 250 | } 251 | 252 | // Update meta if changed 253 | const headerMeta = $("header-meta"); 254 | if (headerMeta && headerMeta.innerHTML !== meta) { 255 | headerMeta.innerHTML = meta; 256 | } 257 | 258 | // Update content 259 | updateContent(renderedHtml); 260 | saveContentToLocalStorage(renderedHtml); 261 | 262 | // Render and scroll 263 | await renderMermaidAndScroll(); 264 | } catch (error) { 265 | console.error("Failed to process WebSocket message:", error); 266 | } 267 | } 268 | 269 | function handleWebSocketClose(event) { 270 | console.log("WebSocket connection closed:", event); 271 | if (!isReloading) { 272 | window.close(); 273 | } 274 | } 275 | 276 | function handleWebSocketOpen() { 277 | console.log("WebSocket connection established"); 278 | } 279 | 280 | function handleWebSocketError(event) { 281 | console.error("WebSocket error:", event); 282 | } 283 | 284 | // Intersection Observer for lazy loading (optional enhancement) 285 | function setupLazyLoading() { 286 | const observerOptions = { 287 | root: null, 288 | rootMargin: "50px", 289 | threshold: 0.01, 290 | }; 291 | 292 | const imageObserver = new IntersectionObserver((entries) => { 293 | entries.forEach((entry) => { 294 | if (entry.isIntersecting) { 295 | const img = entry.target; 296 | if (img.dataset.src && !img.src) { 297 | img.src = img.dataset.src; 298 | imageObserver.unobserve(img); 299 | } 300 | } 301 | }); 302 | }, observerOptions); 303 | 304 | // Observe all images with data-src 305 | $$("img[data-src]").forEach((img) => { 306 | imageObserver.observe(img); 307 | }); 308 | } 309 | 310 | // Initialize modal handler 311 | const modalHandler = createModalHandler(); 312 | 313 | // WebSocket setup 314 | ws.addEventListener("open", handleWebSocketOpen); 315 | ws.addEventListener("message", handleWebSocketMessage); 316 | ws.addEventListener("close", handleWebSocketClose); 317 | ws.addEventListener("error", handleWebSocketError); 318 | 319 | // Window event listeners 320 | window.addEventListener("load", () => { 321 | loadSavedContent(); 322 | setupLazyLoading(); 323 | }); 324 | 325 | window.addEventListener("beforeunload", () => { 326 | isReloading = true; 327 | }); 328 | 329 | // Handle window resize to maintain scroll position 330 | let resizeTimeout; 331 | window.addEventListener("resize", () => { 332 | clearTimeout(resizeTimeout); 333 | resizeTimeout = setTimeout(() => { 334 | if (lastScrollTarget && !$("disable-scrolling")?.checked) { 335 | scrollToEdit(); 336 | } 337 | }, 250); 338 | }); 339 | }); 340 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/mhersson/mpls/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /pkg/parser/extensions.go: -------------------------------------------------------------------------------- 1 | //go:build !cgo 2 | 3 | package parser 4 | 5 | import ( 6 | img64 "github.com/tenkoh/goldmark-img64" 7 | "github.com/yuin/goldmark" 8 | highlighting "github.com/yuin/goldmark-highlighting/v2" 9 | meta "github.com/yuin/goldmark-meta" 10 | "github.com/yuin/goldmark/extension" 11 | ) 12 | 13 | func defaultExtensions() []goldmark.Extender { 14 | return []goldmark.Extender{ 15 | extension.GFM, 16 | highlighting.NewHighlighting( 17 | highlighting.WithStyle(CodeHighlightingStyle), 18 | ), 19 | meta.Meta, 20 | img64.Img64, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /pkg/parser/extensions_cgo.go: -------------------------------------------------------------------------------- 1 | //go:build cgo 2 | 3 | package parser 4 | 5 | import ( 6 | katex "github.com/FurqanSoftware/goldmark-katex" 7 | img64 "github.com/tenkoh/goldmark-img64" 8 | "github.com/yuin/goldmark" 9 | highlighting "github.com/yuin/goldmark-highlighting/v2" 10 | meta "github.com/yuin/goldmark-meta" 11 | "github.com/yuin/goldmark/extension" 12 | ) 13 | 14 | func defaultExtensions() []goldmark.Extender { 15 | return []goldmark.Extender{ 16 | extension.GFM, 17 | highlighting.NewHighlighting( 18 | highlighting.WithStyle(CodeHighlightingStyle), 19 | ), 20 | meta.Meta, 21 | img64.Img64, 22 | &katex.Extender{}, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pkg/parser/parser.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "path/filepath" 7 | "runtime" 8 | "strings" 9 | 10 | img64 "github.com/tenkoh/goldmark-img64" 11 | "github.com/yuin/goldmark" 12 | emoji "github.com/yuin/goldmark-emoji" 13 | meta "github.com/yuin/goldmark-meta" 14 | "github.com/yuin/goldmark/ast" 15 | "github.com/yuin/goldmark/extension" 16 | "github.com/yuin/goldmark/parser" 17 | "github.com/yuin/goldmark/renderer/html" 18 | "github.com/yuin/goldmark/text" 19 | "github.com/yuin/goldmark/util" 20 | "go.abhg.dev/goldmark/wikilink" 21 | ) 22 | 23 | const ScrollAnchor = "mpls-scroll-anchor" 24 | 25 | var ( 26 | oldDocContent map[string]string 27 | CodeHighlightingStyle string 28 | EnableWikiLinks bool 29 | 30 | EnableFootnotes bool 31 | EnableEmoji bool 32 | ) 33 | 34 | func getDocDir(uri string) string { 35 | return filepath.Dir(NormalizePath(uri)) 36 | } 37 | 38 | func NormalizePath(uri string) string { 39 | f := strings.TrimPrefix(uri, "file://") 40 | 41 | if runtime.GOOS == "windows" { 42 | f = strings.TrimPrefix(uri, "file:///") 43 | f = filepath.FromSlash(f) 44 | f = strings.Replace(f, "%3A", ":", 1) 45 | f = strings.ReplaceAll(f, "%20", " ") 46 | } 47 | 48 | return f 49 | } 50 | 51 | type ScrollIDTransformer struct{} 52 | 53 | func (t *ScrollIDTransformer) Transform(doc *ast.Document, reader text.Reader, _ parser.Context) { 54 | currentDocContent := make(map[string]string) 55 | changedNodes := make(map[ast.Node]bool) 56 | 57 | var walk func(ast.Node, string) 58 | walk = func(n ast.Node, path string) { 59 | key := path + ":" + n.Kind().String() 60 | content := string(n.Text(reader.Source())) 61 | currentDocContent[key] = content 62 | 63 | if oldDocContent != nil { 64 | if old, exists := oldDocContent[key]; !exists || old != content { 65 | changedNodes[n] = true 66 | 67 | for p := n.Parent(); p != nil; p = p.Parent() { 68 | if _, ok := p.(*ast.ListItem); ok { 69 | changedNodes[p] = true 70 | 71 | break 72 | } 73 | 74 | if _, ok := p.(*ast.Paragraph); ok { 75 | changedNodes[p] = true 76 | 77 | break 78 | } 79 | 80 | if _, ok := p.(*ast.Heading); ok { 81 | changedNodes[p] = true 82 | 83 | break 84 | } 85 | 86 | if _, ok := p.(*ast.Blockquote); ok { 87 | changedNodes[p] = true 88 | 89 | break 90 | } 91 | } 92 | } 93 | } 94 | 95 | for i, child := 0, n.FirstChild(); child != nil; i, child = i+1, child.NextSibling() { 96 | walk(child, fmt.Sprintf("%s.%d", path, i)) 97 | } 98 | } 99 | 100 | walk(doc, "") 101 | 102 | if len(changedNodes) == 0 { 103 | oldDocContent = currentDocContent 104 | 105 | return 106 | } 107 | 108 | var target ast.Node 109 | 110 | var maxDepth int 111 | 112 | var lastStructural ast.Node 113 | 114 | _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 115 | if !entering { 116 | return ast.WalkContinue, nil 117 | } 118 | 119 | switch n.(type) { 120 | case *ast.Heading, *ast.Paragraph, *ast.ListItem, *ast.Blockquote: 121 | lastStructural = n 122 | 123 | if changedNodes[n] { 124 | depth := 0 125 | for p := n.Parent(); p != nil; p = p.Parent() { 126 | depth++ 127 | } 128 | 129 | if depth > maxDepth { 130 | target, maxDepth = n, depth 131 | } 132 | } 133 | default: 134 | if changedNodes[n] && target == nil { 135 | target = lastStructural 136 | } 137 | } 138 | 139 | return ast.WalkContinue, nil 140 | }) 141 | 142 | if target != nil { 143 | target.SetAttribute([]byte("id"), []byte(ScrollAnchor)) 144 | } 145 | 146 | oldDocContent = currentDocContent 147 | } 148 | 149 | func HTML(document, uri string) (string, map[string]any) { 150 | source := []byte(document) 151 | 152 | dir := getDocDir(uri) 153 | 154 | extensions := defaultExtensions() 155 | 156 | optionalExtensions := map[goldmark.Extender]bool{ 157 | &wikilink.Extender{}: EnableWikiLinks, 158 | extension.Footnote: EnableFootnotes, 159 | emoji.Emoji: EnableEmoji, 160 | } 161 | 162 | for ext, enabled := range optionalExtensions { 163 | if enabled { 164 | extensions = append(extensions, ext) 165 | } 166 | } 167 | 168 | markdown := goldmark.New( 169 | goldmark.WithExtensions(extensions...), 170 | goldmark.WithRendererOptions( 171 | img64.WithPathResolver(img64.ParentLocalPathResolver(dir)), 172 | html.WithUnsafe()), 173 | goldmark.WithParserOptions( 174 | parser.WithASTTransformers( 175 | util.Prioritized(&ScrollIDTransformer{}, 100), 176 | ), 177 | ), 178 | ) 179 | 180 | var buf bytes.Buffer 181 | 182 | ctx := parser.NewContext() 183 | if err := markdown.Convert(source, &buf, parser.WithContext(ctx)); err != nil { 184 | panic(err) 185 | } 186 | 187 | return buf.String(), meta.Get(ctx) 188 | } 189 | -------------------------------------------------------------------------------- /pkg/plantuml/plantuml.go: -------------------------------------------------------------------------------- 1 | package plantuml 2 | 3 | import ( 4 | "bytes" 5 | "compress/flate" 6 | "context" 7 | "encoding/base64" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "net/url" 12 | "time" 13 | ) 14 | 15 | const plantumlMap = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_" 16 | 17 | var Server string 18 | var BasePath string 19 | var DisableTLS bool 20 | 21 | var enc *base64.Encoding 22 | 23 | func init() { 24 | enc = base64.NewEncoding(plantumlMap) 25 | } 26 | 27 | type Plantuml struct { 28 | EncodedUML string 29 | Diagram string 30 | } 31 | 32 | func encode(text string) string { 33 | b := new(bytes.Buffer) 34 | 35 | w, _ := flate.NewWriter(b, flate.BestCompression) 36 | _, _ = w.Write([]byte(text)) 37 | w.Close() 38 | 39 | return enc.EncodeToString(b.Bytes()) 40 | } 41 | 42 | func call(payload string) ([]byte, error) { 43 | path, err := url.JoinPath(BasePath, "png", payload) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | scheme := "https" 49 | if DisableTLS { 50 | scheme = "http" 51 | } 52 | 53 | u := url.URL{Host: Server, Scheme: scheme, Path: path} 54 | 55 | timeout := 10 * time.Second 56 | 57 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 58 | defer cancel() 59 | 60 | req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) 61 | 62 | resp, err := http.DefaultClient.Do(req) 63 | if err != nil { 64 | return nil, fmt.Errorf("failed get diagram: %w", err) 65 | } 66 | 67 | defer resp.Body.Close() 68 | 69 | body, err := io.ReadAll(resp.Body) 70 | if err != nil { 71 | return nil, fmt.Errorf("failed to read response body: %w", err) 72 | } 73 | 74 | return body, nil 75 | } 76 | 77 | func getDiagram(encodedUML string) (string, error) { 78 | svg, err := call(encodedUML) 79 | 80 | var buf bytes.Buffer 81 | 82 | buf.Write([]byte(`plantuml-diagram`)) 87 | 88 | return buf.String(), err 89 | } 90 | 91 | func Encode(uml string) string { 92 | return encode(uml) 93 | } 94 | 95 | func GetDiagram(encodedUML string) (string, error) { 96 | return getDiagram(encodedUML) 97 | } 98 | -------------------------------------------------------------------------------- /screenshots/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhersson/mpls/f016edbf378351541617da9d58d8d347a1b8898f/screenshots/demo.gif --------------------------------------------------------------------------------