Repository: maaslalani/slides Branch: main Commit: c6eea3330053 Files: 53 Total size: 81.8 KB Directory structure: gitextract_svh9gyz7/ ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── dependabot.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── goreleaser.yml │ └── test.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── docs/ │ └── development/ │ └── README.md ├── examples/ │ ├── ascii_slides.md │ ├── code_blocks.md │ ├── custom_remote_theme.md │ ├── custom_theme.md │ ├── import.md │ ├── metadata.md │ ├── preprocess.md │ ├── slides.md │ └── theme.json ├── go.mod ├── go.sum ├── internal/ │ ├── cmd/ │ │ └── serve.go │ ├── code/ │ │ ├── code.go │ │ ├── code_test.go │ │ ├── comments.go │ │ ├── comments_test.go │ │ ├── execute_test.go │ │ └── languages.go │ ├── file/ │ │ ├── file.go │ │ └── file_test.go │ ├── meta/ │ │ ├── meta.go │ │ └── meta_test.go │ ├── model/ │ │ ├── model.go │ │ └── tutorial.md │ ├── navigation/ │ │ ├── navigation.go │ │ ├── navigation_test.go │ │ ├── search.go │ │ └── search_test.go │ ├── process/ │ │ ├── execute_test.go │ │ ├── process.go │ │ └── process_test.go │ └── server/ │ ├── middleware.go │ └── server.go ├── main.go ├── snap/ │ └── snapcraft.yaml └── styles/ ├── styles.go ├── styles_test.go └── theme.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ github: maaslalani ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Version [e.g. 22] **Smartphone (please complete the following information):** - Device: [e.g. iPhone6] - OS: [e.g. iOS8.1] - Browser [e.g. stock browser, safari] - Version [e.g. 22] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "gomod" directory: "/" schedule: interval: "daily" ================================================ FILE: .github/pull_request_template.md ================================================ Fixes #... ### Changes Introduced - - - ================================================ FILE: .github/workflows/goreleaser.yml ================================================ name: goreleaser on: push: tags: - '*' jobs: goreleaser: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Go uses: actions/setup-go@v2 with: go-version: 1.17 - name: Run GoReleaser uses: goreleaser/goreleaser-action@v2 with: distribution: goreleaser version: latest args: release --rm-dist env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/test.yml ================================================ name: test on: [ push, pull_request ] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Go uses: actions/setup-go@v2 with: go-version: 1.17 - name: Lint uses: golangci/golangci-lint-action@v2 - name: Test run: go test -race -v -short ./... ================================================ FILE: .gitignore ================================================ /slides .idea slides_ed25519 slides_ed25519.pub ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Code of Conduct Be nice please! ================================================ FILE: CONTRIBUTING.md ================================================ Take a look at the [Development Docs](./docs/development/README.md). Pull requests are welcome! ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021 Maas Lalani Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ make: go run main.go examples/slides.md test: go test ./... -short build: go build -o slides ================================================ FILE: README.md ================================================ # Slides Slides in your terminal.

Slides Presentation

### Installation [![Homebrew](https://img.shields.io/badge/dynamic/json.svg?url=https://formulae.brew.sh/api/formula/slides.json&query=$.versions.stable&label=homebrew)](https://formulae.brew.sh/formula/slides) [![Snapcraft](https://snapcraft.io/slides/badge.svg)](https://snapcraft.io/slides) [![AUR](https://img.shields.io/aur/version/slides?label=AUR)](https://aur.archlinux.org/packages/slides)
Instructions #### MacOS ``` brew install slides ``` #### Arch ``` yay -S slides ``` #### Nixpkgs (unstable) ``` nix-env -iA nixpkgs.slides ``` #### Any Linux Distro running `snapd` ``` sudo snap install slides ``` #### Go ``` go install github.com/maaslalani/slides@latest ``` From source: ``` git clone https://github.com/maaslalani/slides.git cd slides go install ``` You can also download a binary from the [releases](https://github.com/maaslalani/slides/releases) page.
### Usage Create a simple markdown file that contains your slides: ````markdown # Welcome to Slides A terminal based presentation tool --- ## Everything is markdown In fact, this entire presentation is a markdown file. --- ## Everything happens in your terminal Create slides and present them without ever leaving your terminal. --- ## Code execution ```go package main import "fmt" func main() { fmt.Println("Execute code directly inside the slides") } ``` You can execute code inside your slides by pressing ``, the output of your command will be displayed at the end of the current slide. --- ## Pre-process slides You can add a code block with three tildes (`~`) and write a command to run *before* displaying the slides, the text inside the code block will be passed as `stdin` to the command and the code block will be replaced with the `stdout` of the command. ``` ~~~graph-easy --as=boxart [ A ] - to -> [ B ] ~~~ ``` The above will be pre-processed to look like: ┌───┐ to ┌───┐ │ A │ ────> │ B │ └───┘ └───┘ For security reasons, you must pass a file that has execution permissions for the slides to be pre-processed. You can use `chmod` to add these permissions. ```bash chmod +x file.md ``` ```` Checkout the [example slides](https://github.com/maaslalani/slides/tree/main/examples). Then, to present, run: ``` slides presentation.md ``` If given a file name, `slides` will automatically look for changes in the file and update the presentation live. `slides` also accepts input through `stdin`: ``` curl http://example.com/slides.md | slides ``` Go to the first slide with the following key sequence: * g g Go to the next slide with any of the following key sequences: * space * right * down * enter * n * j * l * Page Down * number + any of the above (go forward n slides) Go to the previous slide with any of the following key sequences: * left * up * p * h * k * N * Page Up * number + any of the above (go back n slides) Go to a specific slide with the following key sequence: * number + G Go to the last slide with the following key: * G ### Search To quickly jump to the right slide, you can use the search function. Press /, enter your search term and press Enter (*The search term is interpreted as a regular expression. The `/i` flag causes case-insensitivity.*). Press ctrl+n after a search to go to the next search result. ### Code Execution If slides finds a code block on the current slides it can execute the code block and display the result as virtual text on the screen. Press ctrl+e on a slide with a code block to execute it and display the result. ### Pre-processing You can add a code block with three tildes (`~`) and write a command to run *before* displaying the slides, the text inside the code block will be passed as `stdin` to the command and the code block will be replaced with the `stdout` of the command. Wrap the pre-processed block in three backticks to keep proper formatting and new lines. ```` ``` ~~~graph-easy --as=boxart [ A ] - to -> [ B ] ~~~ ``` ```` The above will be pre-processed to look like: ``` ┌───┐ to ┌───┐ │ A │ ────> │ B │ └───┘ └───┘ ``` For security reasons, you must pass a file that has execution permissions for the slides to be pre-processed. You can use `chmod` to add these permissions. ```bash chmod +x file.md ``` ### Configuration `slides` allows you to customize your presentation's look and feel with metadata at the top of your `slides.md`. > This section is entirely optional, `slides` will use sensible defaults if this section or any field in the section is omitted. ```yaml --- theme: ./path/to/theme.json author: Gopher date: MMMM dd, YYYY paging: Slide %d / %d --- ``` * `theme`: Path to `json` file containing a [glamour theme](https://github.com/charmbracelet/glamour/tree/master/styles), can also be a link to a remote `json` file which slides will fetch before presenting. * `author`: A `string` to display on the bottom-left corner of the presentation view. Defaults to the OS current user's full name. Can be empty to hide the author. * `date`: A `string` that is used to format today's date in the `YYYY-MM-DD` format. If the date is not a valid format, the string will be displayed. Defaults to `YYYY-MM-DD`. * `paging`: A `string` that contains 0 or more `%d` directives. The first `%d` will be replaced with the current slide number and the second `%d` will be replaced with the total slides count. Defaults to `Slide %d / %d`. You will need to surround the paging value with quotes if it starts with `%`. #### Date format Given the date _January 02, 2006_: | Value | Translates to | |--------|---------------| | `YYYY` | 2006 | | `YY` | 06 | | `MMMM` | January | | `MMM` | Jan | | `MM` | 01 | | `mm` | 1 | | `DD` | 02 | | `dd` | 2 | ### SSH Slides is accessible over `ssh` if hosted on a machine through the `slides serve [file]` command. On a machine, run: ``` slides serve [file] ``` Then, on another machine (or same machine), `ssh` into the port specified by the `slides serve [file]` command: ``` ssh 127.0.0.1 -p 53531 ``` You will be able to access the presentation hosted over SSH! You can use this to present with `slides` from a computer that doesn't have `slides` installed, but does have `ssh`. Or, let your viewers have access to the slides on their own computer without needing to download `slides` and the presentation file. ### Alternatives **Credits**: This project was heavily inspired by [`lookatme`](https://github.com/d0c-s4vage/lookatme). * [`lookatme`](https://github.com/d0c-s4vage/lookatme) * [`sli.dev`](https://sli.dev/) * [`sent`](https://tools.suckless.org/sent/) * [`presenterm`](https://github.com/mfontanini/presenterm) ### Development See the [development documentation](./docs/development) ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Reporting a Vulnerability Email [maas@lalani.dev](mailto:maas@lalani.dev) ================================================ FILE: docs/development/README.md ================================================ # Development Make changes, and test them by running: ``` make ``` This will run `go run main.go examples/slides.md`, you can then ensure everything still works. If you're adding a feature that requires a specific piece of markdown, you can add a file with your test case into `examples/.md` and iterate on that file. Ensure tests are still passing ``` make test ``` ### Breaking Changes Most changes should be entirely backwards compatible. Ensure that `slides examples/slides.md` still works. ### Codebase Initialization (command-line interface, defaults) happens in [`cmd/root.go`](../../cmd/root.go). Interaction (controls, input, output) happens in [`model.go`](../../internal/model/model.go) Optional configuration (e.g. `theme: dark`) can be added to [`meta.go`](../../internal/meta/meta.go) ================================================ FILE: examples/ascii_slides.md ================================================ --- theme: ascii --- # Welcome to Slides A terminal based presentation tool ```go package main import "fmt" func main() { fmt.Println("Written in Go!") } ``` --- ## Everything is markdown In fact this entire presentation is a markdown file --- # h1 ## h2 ### h3 #### h4 ##### h5 ###### h6 # Markdown components You can use everything in markdown! * Like bulleted list * You know the deal 1. Numbered lists too | Tables | Too | | ------ | ------ | | Even | Tables | --- All you need to do is separate slides with triple dashes `---` on a separate line, like so: ```markdown # Slide 1 Some stuff --- # Slide 2 Some other stuff ``` ================================================ FILE: examples/code_blocks.md ================================================ # Code blocks Slides allows you to execute code blocks directly inside your slides! Just press `ctrl+e` and the result of the code block will be displayed as virtual text in your slides. Currently supported languages: * `bash` * `zsh` * `fish` * `elixir` * `go` * `javascript` * `python` * `ruby` * `perl` * `rust` * `java` * `cpp` * `swift` * `dart` * `v` --- ### Bash ```bash ls ``` --- ### Zsh ```zsh ls ``` --- ### Fish ```fish ls ``` --- ### Elixir ```elixir IO.puts "Hello, world!" ``` --- ### Go Use `///` to hide verbose code but still allow the ability to execute it. If you press `y` to copy (yank) this code block it will return the full snippet. And, if you press `ctrl+e` it will run the program without error, even though what is being displayed is not a valid go program because we have commented out some boilerplate to focus on the important parts. ```go ///package main /// import "fmt" /// ///func main() { fmt.Println("Hello, world!") ///} ``` --- ### Javascript ```javascript console.log("Hello, world!") ``` --- ### Lua ```lua print("Hello, World!") ``` --- ### Python ```python print("Hello, world!") ``` --- ### Ruby ```ruby puts "Hello, world!" ``` --- ### Perl ```perl print ("hello, world"); ``` --- ### Rust ```rust fn main() { println!("Hello, world!"); } ``` --- ### Java ```java public class Main { public static void main(String[] args) { System.out.println("Hello, world!"); } } ``` --- ### Julia ```julia println("Hello, world!") ``` --- ### C++ ```cpp #include int main() { std::cout << "Hello, world!" << std::endl; return 0; } ``` --- ### Swift ```swift print("Hello, world!") ``` --- ### Dart ```dart void main() { print("Hello, world!"); } ``` ### V ```v println('Hello, world!') ``` --- ### Scala ```scala //> using dep com.lihaoyi::pprint:0.8.1 object Main extends App { println("Hello") } ``` ================================================ FILE: examples/custom_remote_theme.md ================================================ --- theme: https://github.com/maaslalani/slides/raw/main/styles/theme.json --- # Slides The theme of this slide is fetched from https://github.com/maaslalani/slides/raw/main/styles/theme.json, the title above should be green. ================================================ FILE: examples/custom_theme.md ================================================ --- theme: ./examples/theme.json --- # Slides The above title should be orange and be prefixed with `CUSTOM`. ================================================ FILE: examples/import.md ================================================ This is just an example of how to import text from other files with preprocess.md ================================================ FILE: examples/metadata.md ================================================ --- author: Gopher date: May 22, 2022 paging: Page %d of %d --- # Metadata Example Customize the bottom information bar by adding metadata to your `slides.md` file. ``` --- author: Gopher date: May 22, 2022 paging: Page %d of %d --- ``` --- # Metadata Example You can also hide the bottom bar by leaving all of the fields blank ``` --- author: "" date: "" paging: "" --- ``` ================================================ FILE: examples/preprocess.md ================================================ # Slides You can add a code block with three tildes (~) and write a command to run before displaying the slides, the text inside the code block will be passed as stdin to the command and the code block will be replaced with the stdout of the command. ``` ~~~graph-easy --as=boxart [ A ] - to -> [ B ] ~~~ ``` The above will be pre-processed to look like: NOTE: You need `graph-easy` installed and in your `$PATH` ``` ┌───┐ to ┌───┐ │ A │ ────> │ B │ └───┘ └───┘ ``` For security reasons, you must pass a file that has execution permissions for the slides to be pre-processed. ``` chmod +x file.md ``` --- ~~~sd replaced processed This content will be passed in as stdin and will be replaced. ~~~ --- Any command will work ~~~echo "You can do whatever, really" This doesn't matter, since it will be replaced by the stdout of the command above because the command will disregard stdin. ~~~ --- You can use this to import snippets of code from other files: ~~~xargs cat examples/import.md ~~~ --- ## More pre-process examples: ### PlantUML ``` ~~~plantuml -utxt -pipe @startuml A --> B: to @enduml ~~~ ``` The above will be pre-processed to look like: NOTE: You need `plantuml` installed and in your `$PATH` ``` ┌─┐ ┌─┐ │A│ │B│ └┬┘ └┬┘ │ to │ │ ─ ─ ─ ─ ─ >│ ┌┴┐ ┌┴┐ │A│ │B│ └─┘ └─┘ ================================================ FILE: examples/slides.md ================================================ # Welcome to Slides A terminal based presentation tool ```go package main import "fmt" func main() { fmt.Println("Written in Go!") } ``` --- ## Everything is markdown In fact this entire presentation is a markdown file --- # h1 ## h2 ### h3 #### h4 ##### h5 ###### h6 --- # Markdown components You can use everything in markdown! * Like bulleted list * You know the deal 1. Numbered lists too --- # Tables | Tables | Too | | ------ | ------ | | Even | Tables | --- # Graphs ``` digraph { rankdir = LR; a -> b; b -> c; } ``` ``` ┌───┐ ┌───┐ ┌───┐ │ a │ ──▶ │ b │ ──▶ │ c │ └───┘ └───┘ └───┘ ``` --- All you need to do is separate slides with triple dashes `---` on a separate line, like so: ```markdown # Slide 1 Some stuff --- # Slide 2 Some other stuff ``` ================================================ FILE: examples/theme.json ================================================ { "document": { "block_prefix": "\n", "block_suffix": "\n", "color": "252", "margin": 2 }, "block_quote": { "indent": 1, "indent_token": "│ " }, "paragraph": {}, "list": { "level_indent": 2 }, "heading": { "block_suffix": "\n", "color": "39", "bold": true }, "h1": { "prefix": "CUSTOM ", "suffix": " ", "color": "#fa0", "bold": true }, "h2": { "prefix": "▓▓▓ ", "color": "#1cc" }, "h3": { "prefix": "▒▒▒▒ ", "color": "#29c" }, "h4": { "color": "#559", "prefix": "░░░░░ " }, "h5": {}, "h6": {}, "text": {}, "strikethrough": { "crossed_out": true }, "emph": { "italic": true }, "strong": { "bold": true }, "hr": { "color": "240", "format": "\n--------\n" }, "item": { "block_prefix": "• " }, "enumeration": { "block_prefix": ". " }, "task": { "ticked": "[✓] ", "unticked": "[ ] " }, "link": { "color": "30", "underline": true }, "link_text": { "color": "35", "bold": true }, "image": { "color": "212", "underline": true }, "image_text": { "color": "243", "format": "Image: {{.text}} →" }, "code": { "prefix": " ", "suffix": " ", "color": "203", "background_color": "236" }, "code_block": { "theme": "dracula", "margin": 2 }, "table": { "center_separator": "┼", "column_separator": "│", "row_separator": "─" }, "definition_list": {}, "definition_term": {}, "definition_description": { "block_prefix": "\n🠶 " }, "html_block": {}, "html_span": {} } ================================================ FILE: go.mod ================================================ module github.com/maaslalani/slides go 1.22 require ( github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v0.18.0 github.com/charmbracelet/bubbletea v0.26.2 github.com/charmbracelet/glamour v0.7.0 github.com/charmbracelet/lipgloss v0.10.0 github.com/charmbracelet/ssh v0.0.0-20240507011153-ec70bd03034c github.com/charmbracelet/wish v1.4.0 github.com/muesli/coral v1.0.0 github.com/muesli/termenv v0.15.2 github.com/stretchr/testify v1.9.0 gopkg.in/yaml.v2 v2.4.0 ) require ( github.com/alecthomas/chroma/v2 v2.8.0 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/keygen v0.5.0 // indirect github.com/charmbracelet/log v0.4.0 // indirect github.com/charmbracelet/x/errors v0.0.0-20240117030013-d31dba354651 // indirect github.com/charmbracelet/x/exp/term v0.0.0-20240328150354-ab9afc214dfd // indirect github.com/creack/pty v1.1.21 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/gorilla/css v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.18 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/microcosm-cc/bluemonday v1.0.25 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/yuin/goldmark v1.5.4 // indirect github.com/yuin/goldmark-emoji v1.0.2 // indirect golang.org/x/crypto v0.21.0 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/net v0.22.0 // indirect golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.20.0 // indirect golang.org/x/term v0.20.0 // indirect golang.org/x/text v0.14.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: go.sum ================================================ github.com/alecthomas/assert/v2 v2.2.1 h1:XivOgYcduV98QCahG8T5XTezV5bylXe+lBxLG2K2ink= github.com/alecthomas/assert/v2 v2.2.1/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= github.com/alecthomas/chroma/v2 v2.8.0 h1:w9WJUjFFmHHB2e8mRpL9jjy3alYDlU0QLDezj1xE264= github.com/alecthomas/chroma/v2 v2.8.0/go.mod h1:yrkMI9807G1ROx13fhe1v6PN2DDeaR73L3d+1nmYQtw= github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= github.com/charmbracelet/bubbletea v0.26.2 h1:Eeb+n75Om9gQ+I6YpbCXQRKHt5Pn4vMwusQpwLiEgJQ= github.com/charmbracelet/bubbletea v0.26.2/go.mod h1:6I0nZ3YHUrQj7YHIHlM8RySX4ZIthTliMY+W8X8b+Gs= github.com/charmbracelet/glamour v0.7.0 h1:2BtKGZ4iVJCDfMF229EzbeR1QRKLWztO9dMtjmqZSng= github.com/charmbracelet/glamour v0.7.0/go.mod h1:jUMh5MeihljJPQbJ/wf4ldw2+yBP59+ctV36jASy7ps= github.com/charmbracelet/keygen v0.5.0 h1:XY0fsoYiCSM9axkrU+2ziE6u6YjJulo/b9Dghnw6MZc= github.com/charmbracelet/keygen v0.5.0/go.mod h1:DfvCgLHxZ9rJxdK0DGw3C/LkV4SgdGbnliHcObV3L+8= github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= github.com/charmbracelet/ssh v0.0.0-20240507011153-ec70bd03034c h1:nsxEhgGnHTGPh5qXr7EBHOKaaJ1nmQWIcI5TLRPYDqo= github.com/charmbracelet/ssh v0.0.0-20240507011153-ec70bd03034c/go.mod h1:8/Ve8iGRRIGFM1kepYfRF2pEOF5Y3TEZYoJaA54228U= github.com/charmbracelet/wish v1.4.0 h1:pL1uVP/YuYgJheHEj98teZ/n6pMYnmlZq/fcHvomrfc= github.com/charmbracelet/wish v1.4.0/go.mod h1:ew4/MjJVfW/akEO9KmrQHQv1F7bQRGscRMrA+KtovTk= github.com/charmbracelet/x/errors v0.0.0-20240117030013-d31dba354651 h1:3RXpZWGWTOeVXCTv0Dnzxdv/MhNUkBfEcbaTY0zrTQI= github.com/charmbracelet/x/errors v0.0.0-20240117030013-d31dba354651/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/term v0.0.0-20240328150354-ab9afc214dfd h1:HqBjkSFXXfW4IgX3TMKipWoPEN08T3Pi4SA/3DLss/U= github.com/charmbracelet/x/exp/term v0.0.0-20240328150354-ab9afc214dfd/go.mod h1:6GZ13FjIP6eOCqWU4lqgveGnYxQo9c3qBzHPeFu4HBE= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg= github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/coral v1.0.0 h1:odyqkoEg4aJAINOzvnjN4tUsdp+Zleccs7tRIAkkYzU= github.com/muesli/coral v1.0.0/go.mod h1:bf91M/dkp7iHQw73HOoR9PekdTJMTD6ihJgWoDitde8= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark-emoji v1.0.2 h1:c/RgTShNgHTtc6xdz2KKI74jJr6rWi7FPgnP9GAsO5s= github.com/yuin/goldmark-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: internal/cmd/serve.go ================================================ package cmd import ( "context" "log" "os" "os/signal" "strconv" "syscall" "time" "github.com/maaslalani/slides/internal/model" "github.com/maaslalani/slides/internal/navigation" "github.com/maaslalani/slides/internal/server" "github.com/muesli/coral" ) var ( host string port int keyPath string err error fileName string ) // ServeCmd is the command for serving the presentation. It starts the slides // server allowing for connections. var ServeCmd = &coral.Command{ Use: "serve ", Aliases: []string{"server"}, Short: "Start an SSH server to run slides", Args: coral.ArbitraryArgs, RunE: func(cmd *coral.Command, args []string) error { k := os.Getenv("SLIDES_SERVER_KEY_PATH") if k != "" { keyPath = k } h := os.Getenv("SLIDES_SERVER_HOST") if h != "" { host = h } p := os.Getenv("SLIDES_SERVER_PORT") if p != "" { port, _ = strconv.Atoi(p) } if len(args) > 0 { fileName = args[0] } presentation := model.Model{ Page: 0, Date: time.Now().Format("2006-01-02"), FileName: fileName, Search: navigation.NewSearch(), } err = presentation.Load() if err != nil { return err } s, err := server.NewServer(keyPath, host, port, presentation) if err != nil { return err } done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) log.Printf("Starting Slides server on %s:%d", host, port) go func() { if err = s.Start(); err != nil { log.Fatalln(err) } }() <-done log.Print("Stopping Slides server") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() }() if err := s.Shutdown(ctx); err != nil { return err } return err }, } func init() { ServeCmd.Flags().StringVar(&keyPath, "keyPath", "slides", "Server private key path") ServeCmd.Flags().StringVar(&host, "host", "localhost", "Server host to bind to") ServeCmd.Flags().IntVar(&port, "port", 53531, "Server port to bind to") } ================================================ FILE: internal/code/code.go ================================================ package code import ( "errors" "os" "os/exec" "path/filepath" "regexp" "strings" "time" ) // Block represents a code block. type Block struct { Code string Language string } // Result represents the output for an executed code block. type Result struct { Out string ExitCode int ExecutionTime time.Duration } // ?: means non-capture group var re = regexp.MustCompile("(?s)(?:```|~~~)(\\w+)\n(.*?)\n(?:```|~~~)\\s?") var ( // ErrParse is the returned error when we cannot parse the code block (i.e. // there is no code block on the current slide) or the code block is // incorrectly written. ErrParse = errors.New("Error: could not parse code block") ) // Parse takes a block of markdown and returns an array of Block's with code // and associated languages func Parse(markdown string) ([]Block, error) { matches := re.FindAllStringSubmatch(markdown, -1) var rv []Block for _, match := range matches { // There was either no language specified or no code block // Either way, we cannot execute the expression if len(match) < 3 { continue } rv = append(rv, Block{ Language: match[1], Code: RemoveComments(match[2]), }) } if len(rv) == 0 { return nil, ErrParse } return rv, nil } const ( // ExitCodeInternalError represents the exit code in which the code // executing the code didn't work. ExitCodeInternalError = -1 ) // Execute takes a code.Block and returns the output of the executed code func Execute(code Block) Result { // Check supported language language, ok := Languages[code.Language] if !ok { return Result{ Out: "Error: unsupported language", ExitCode: ExitCodeInternalError, } } // Write the code block to a temporary file f, err := os.CreateTemp(os.TempDir(), "slides-*."+Languages[code.Language].Extension) if err != nil { return Result{ Out: "Error: could not create file", ExitCode: ExitCodeInternalError, } } defer f.Close() defer os.Remove(f.Name()) _, err = f.WriteString(code.Code) if err != nil { return Result{ Out: "Error: could not write to file", ExitCode: ExitCodeInternalError, } } var ( output strings.Builder exitCode int ) // replacer for commands repl := strings.NewReplacer( "", f.Name(), // : file name without extension and without path "", filepath.Base(strings.TrimSuffix(f.Name(), filepath.Ext(f.Name()))), "", filepath.Dir(f.Name()), ) // For accuracy of program execution speed, we can't put anything after // recording the start time or before recording the end time. start := time.Now() for _, c := range language.Commands { var command []string // replace , and in commands for _, v := range c { command = append(command, repl.Replace(v)) } // execute and write output cmd := exec.Command(command[0], command[1:]...) out, err := cmd.Output() if err != nil { output.Write([]byte(err.Error())) } else { output.Write(out) } // update status code if err != nil { if cmd.ProcessState != nil { exitCode = cmd.ProcessState.ExitCode() } else { exitCode = 1 // non-zero } } } end := time.Now() return Result{ Out: output.String(), ExitCode: exitCode, ExecutionTime: end.Sub(start), } } ================================================ FILE: internal/code/code_test.go ================================================ package code_test import ( "testing" "github.com/maaslalani/slides/internal/code" ) func TestParse(t *testing.T) { tt := []struct { markdown string expected []code.Block }{ // We can't put backticks ``` // in multi-line strings, ~~~ instead { markdown: ` ~~~ruby puts "Hello, world!" ~~~ `, expected: []code.Block{ { Code: `puts "Hello, world!"`, Language: "ruby", }, }, }, { markdown: ` ~~~go fmt.Println("Hello, world!") ~~~ `, expected: []code.Block{ { Code: `fmt.Println("Hello, world!")`, Language: "go", }, }, }, { markdown: ` ~~~python print("Hello, world!") ~~~`, expected: []code.Block{ { Code: `print("Hello, world!")`, Language: "python", }, }, }, { markdown: ` # Welcome to Slides A terminal based presentation tool ~~~go package main import "fmt" func main() { fmt.Println("Written in Go!") } ~~~ `, expected: []code.Block{ { Code: `package main import "fmt" func main() { fmt.Println("Written in Go!") }`, Language: "go", }, }, }, { markdown: ` # Slide 1 Just a regular slide, no code block `, expected: nil, }, { markdown: ``, expected: nil, }, { markdown: ` ~~~ruby puts "Hello, world!" ~~~ ~~~go fmt.Println("Hello, world!") ~~~ `, expected: []code.Block{ { Code: `puts "Hello, world!"`, Language: "ruby", }, { Code: `fmt.Println("Hello, world!")`, Language: "go", }, }, }, } for _, tc := range tt { blocks, _ := code.Parse(tc.markdown) if len(blocks) != len(tc.expected) { t.Errorf("parse fail: incorrect size of blocks") } for i, block := range blocks { expected := tc.expected[i] if block.Code != expected.Code { t.Log(block.Code) t.Log(expected.Code) t.Fatal("parse failed: incorrect code") } if block.Language != expected.Language { t.Fatalf("incorrect language, got %s, want %s", block.Language, expected.Language) } } } } ================================================ FILE: internal/code/comments.go ================================================ package code import ( "regexp" "strings" ) const comment = "///" var commentRegexp = regexp.MustCompile("(?m)[\r\n]+^" + comment + ".*$") // HideComments removes all comments from the given content. func HideComments(content string) string { return commentRegexp.ReplaceAllString(content, "") } // RemoveComments strips all the comments from the given content. // This is useful for when we want to actually use the content of the comments. func RemoveComments(content string) string { return strings.ReplaceAll(content, comment, "") } ================================================ FILE: internal/code/comments_test.go ================================================ package code import "testing" func TestHidesComments(t *testing.T) { content := ` ///package main /// ///import "fmt" /// ///func main() { fmt.Println("Hello, world!") ///}` expected := ` fmt.Println("Hello, world!")` if HideComments(content) != expected { t.Errorf("Expected %s, got %s", expected, HideComments(content)) } } func TestNoComments(t *testing.T) { content := ` package main import "fmt" func main() { fmt.Println("Hello, world!") }` expected := content if HideComments(content) != expected { t.Errorf("Expected %s, got %s", expected, HideComments(content)) } if RemoveComments(content) != expected { t.Errorf("Expected %s, got %s", expected, HideComments(content)) } } func TestRemoveComments(t *testing.T) { content := ` ///package main /// ///import "fmt" /// ///func main() { fmt.Println("Hello, world!") ///}` expected := ` package main import "fmt" func main() { fmt.Println("Hello, world!") }` if RemoveComments(content) != expected { t.Errorf("Expected %s, got %s", expected, RemoveComments(content)) } } ================================================ FILE: internal/code/execute_test.go ================================================ package code_test import ( "testing" "github.com/maaslalani/slides/internal/code" ) func TestExecute(t *testing.T) { tt := []struct { block code.Block expected code.Result }{ { block: code.Block{ Code: ` package main import "fmt" func main() { fmt.Print("Hello, go!") } `, Language: "go", }, expected: code.Result{ Out: "Hello, go!", ExitCode: 0, }, }, { block: code.Block{ Code: `echo "Hello, bash!"`, Language: "bash", }, expected: code.Result{ Out: "Hello, bash!\n", ExitCode: 0, }, }, { block: code.Block{ Code: `Invalid Code`, Language: "bash", }, expected: code.Result{ Out: "exit status 127", ExitCode: 127, }, }, { block: code.Block{ Code: `Invalid Code`, Language: "invalid", }, expected: code.Result{ Out: "Error: unsupported language", ExitCode: code.ExitCodeInternalError, }, }, } for _, tc := range tt { r := code.Execute(tc.block) if r.Out != tc.expected.Out { t.Fatalf("invalid output for lang %s, got %s, want %s | %+v", tc.block.Language, r.Out, tc.expected.Out, r) } if r.ExitCode != tc.expected.ExitCode { t.Fatalf("unexpected exit code, got %d, want %d", r.ExitCode, tc.expected.ExitCode) } } } ================================================ FILE: internal/code/languages.go ================================================ package code // cmds: Multiple commands; placeholders can be used // Placeholders , and can be used. type cmds [][]string // Language represents a programming language with it Extension and Commands to // execute its programs. type Language struct { // Extension represents the file extension used by this language. Extension string // Commands [][]string // placeholders: file name (without // extension), file name, path without file name Commands cmds } // Supported Languages const ( Bash = "bash" Zsh = "zsh" Fish = "fish" Elixir = "elixir" Go = "go" Javascript = "javascript" Lua = "lua" OCaml = "ocaml" Perl = "perl" Python = "python" Ruby = "ruby" Rust = "rust" Java = "java" Julia = "julia" Cpp = "cpp" Swift = "swift" Dart = "dart" V = "v" Scala = "scala" Haskell = "haskell" ) // Languages is a map of supported languages with their extensions and commands // to run to execute the program. var Languages = map[string]Language{ Bash: { Extension: "sh", Commands: cmds{{"bash", ""}}, }, Zsh: { Extension: "zsh", Commands: cmds{{"zsh", ""}}, }, Fish: { Extension: "fish", Commands: cmds{{"fish", ""}}, }, Elixir: { Extension: "exs", Commands: cmds{{"elixir", ""}}, }, Go: { Extension: "go", Commands: cmds{{"go", "run", ""}}, }, Javascript: { Extension: "js", Commands: cmds{{"node", ""}}, }, Lua: { Extension: "lua", Commands: cmds{{"lua", ""}}, }, Ruby: { Extension: "rb", Commands: cmds{{"ruby", ""}}, }, OCaml: { Extension: "ml", Commands: cmds{{"ocaml", ""}}, }, Python: { Extension: "py", Commands: cmds{{"python", ""}}, }, Perl: { Extension: "pl", Commands: cmds{{"perl", ""}}, }, Rust: { Extension: "rs", Commands: cmds{ // compile code {"rustc", "", "-o", "/.run"}, // run compiled file {"/.run"}, }, }, Java: { Extension: "java", Commands: cmds{{"java", ""}}, }, Julia: { Extension: "jl", Commands: cmds{{"julia", ""}}, }, Cpp: { Extension: "cpp", Commands: cmds{ {"g++", "-std=c++20", "-o", "/.run", ""}, {"/.run"}, }, }, Swift: { Extension: "swift", Commands: cmds{{"swift", ""}}, }, Dart: { Extension: "dart", Commands: cmds{{"dart", ""}}, }, V: { Extension: "v", Commands: cmds{{"v", "run", ""}}, }, Scala: { Extension: "sc", Commands: cmds{{"scala-cli", "run", ""}}, }, Haskell: { Extension: "hs", Commands: cmds{{"runghc", ""}}, }, } ================================================ FILE: internal/file/file.go ================================================ // Package file includes utility functions // for working with the filesystem package file import ( "io/fs" "os" ) // Exists is a helper to verify // that the provided filepath exists // on the system func Exists(filepath string) bool { info, err := os.Stat(filepath) if os.IsNotExist(err) { return false } return !info.IsDir() } // IsExecutable returns whether a file has execution permissions func IsExecutable(s fs.FileInfo) bool { return s.Mode().Perm()&0111 == 0111 } ================================================ FILE: internal/file/file_test.go ================================================ package file_test import ( "fmt" "io/fs" "os" "testing" "github.com/maaslalani/slides/internal/file" "github.com/stretchr/testify/assert" ) func TestExists(t *testing.T) { tests := []struct { name string filepath string want bool }{ {name: "Find file exists", filepath: "file.go", want: true}, {name: "Return false for missing file", filepath: "afilethatdoesntexist.go", want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { isExist := file.Exists(tt.filepath) if isExist { assert.FileExists(t, tt.filepath) } assert.Equal(t, tt.want, isExist) }) } } func TestIsExecutable(t *testing.T) { tests := []struct { perm fs.FileMode expected bool }{ {0101, false}, {0111, true}, {0644, false}, {0666, false}, {0777, true}, } for _, tc := range tests { t.Run(fmt.Sprint(tc.perm), func(t *testing.T) { tmp, err := os.CreateTemp(os.TempDir(), "slides-*") if err != nil { t.Fatal("failed to create temp file") } defer os.Remove(tmp.Name()) err = tmp.Chmod(tc.perm) if err != nil { t.Fatal(err) } s, err := tmp.Stat() if err != nil { t.Fatal("failed to stat file") } want := tc.expected got := file.IsExecutable(s) if tc.expected != got { t.Log(want) t.Log(got) t.Fatalf("IsExecutable returned an incorrect result, want: %t, got %t", want, got) } }) } } ================================================ FILE: internal/meta/meta.go ================================================ // Package meta implements markdown frontmatter parsing for simple // slides configuration package meta import ( "os" "os/user" "strings" "time" "gopkg.in/yaml.v2" ) // Temporary structure to differentiate values not present in the YAML header // from values set to empty strings in the YAML header. We replace values not // set by defaults values when parsing a header. type parsedMeta struct { Theme *string `yaml:"theme"` Author *string `yaml:"author"` Date *string `yaml:"date"` Paging *string `yaml:"paging"` } // Meta contains all of the data to be parsed // out of a markdown file's header section type Meta struct { Theme string Author string Date string Paging string } // New creates a new instance of the // slideshow meta header object func New() *Meta { return &Meta{} } // Parse parses metadata from a slideshows header slide // including theme information // // If no front matter is provided, it will fallback to the default theme and // return false to acknowledge that there is no front matter in this slide func (m *Meta) Parse(header string) (*Meta, bool) { fallback := &Meta{ Theme: defaultTheme(), Author: defaultAuthor(), Date: defaultDate(), Paging: defaultPaging(), } var tmp parsedMeta err := yaml.Unmarshal([]byte(header), &tmp) if err != nil { return fallback, false } if tmp.Theme != nil { m.Theme = *tmp.Theme } else { m.Theme = fallback.Theme } if tmp.Author != nil { m.Author = *tmp.Author } else { m.Author = fallback.Author } if tmp.Date != nil { parsedDate := parseDate(*tmp.Date) if parsedDate == *tmp.Date { m.Date = *tmp.Date } else { m.Date = time.Now().Format(parsedDate) } } else { m.Date = fallback.Date } if tmp.Paging != nil { m.Paging = *tmp.Paging } else { m.Paging = fallback.Paging } return m, true } func defaultTheme() string { theme := os.Getenv("GLAMOUR_STYLE") if theme == "" { return "default" } return theme } func defaultAuthor() string { user, err := user.Current() if err != nil { return "" } return user.Name } func defaultDate() string { return time.Now().Format(parseDate("YYYY-MM-DD")) } func defaultPaging() string { return "Slide %d / %d" } func parseDate(value string) string { pairs := [][]string{ {"YYYY", "2006"}, {"YY", "06"}, {"MMMM", "January"}, {"MMM", "Jan"}, {"MM", "01"}, {"mm", "1"}, {"DD", "02"}, {"dd", "2"}, } for _, p := range pairs { value = strings.ReplaceAll(value, p[0], p[1]) } return value } ================================================ FILE: internal/meta/meta_test.go ================================================ package meta_test import ( "fmt" "os/user" "testing" "time" "github.com/maaslalani/slides/internal/meta" "github.com/stretchr/testify/assert" ) func TestMeta_ParseHeader(t *testing.T) { user, _ := user.Current() date := time.Now().Format("2006-01-02") tests := []struct { name string slideshow string want *meta.Meta }{ { name: "Parse theme from header", slideshow: fmt.Sprintf("---\ntheme: %q\n", "dark"), want: &meta.Meta{ Theme: "dark", Author: user.Name, Date: date, Paging: "Slide %d / %d", }, }, { name: "Fallback to default if no theme provided", slideshow: "\n# Header Slide\n > Subtitle\n", want: &meta.Meta{ Theme: "default", Author: user.Name, Date: date, Paging: "Slide %d / %d", }, }, { name: "Parse author from header", slideshow: fmt.Sprintf("---\nauthor: %q\n", "gopher"), want: &meta.Meta{ Theme: "default", Author: "gopher", Date: date, Paging: "Slide %d / %d", }, }, { name: "Fallback to default if no author provided", slideshow: "\n# Header Slide\n > Subtitle\n", want: &meta.Meta{ Theme: "default", Author: user.Name, Date: date, Paging: "Slide %d / %d", }, }, { name: "Parse static date from header", slideshow: fmt.Sprintf("---\ndate: %q\n", "31/01/1970"), want: &meta.Meta{ Theme: "default", Author: user.Name, Date: "31/01/1970", Paging: "Slide %d / %d", }, }, { name: "Parse go-styled date from header", slideshow: fmt.Sprintf("---\ndate: %q\n", "MMM dd, YYYY"), want: &meta.Meta{ Theme: "default", Author: user.Name, Date: time.Now().Format("Jan 2, 2006"), Paging: "Slide %d / %d", }, }, { name: "Parse YYYY-MM-DD date from header", slideshow: fmt.Sprintf("---\ndate: %q\n", "YYYY-MM-DD"), want: &meta.Meta{ Theme: "default", Author: user.Name, Date: time.Now().Format("2006-01-02"), Paging: "Slide %d / %d", }, }, { name: "Parse dd/mm/YY date from header", slideshow: fmt.Sprintf("---\ndate: %q\n", "dd/mm/YY"), want: &meta.Meta{ Theme: "default", Author: user.Name, Date: time.Now().Format("2/1/06"), Paging: "Slide %d / %d", }, }, { name: "Parse MMM dd, YYYY date from header", slideshow: fmt.Sprintf("---\ndate: %q\n", "MMM dd, YYYY"), want: &meta.Meta{ Theme: "default", Author: user.Name, Date: time.Now().Format("Jan 2, 2006"), Paging: "Slide %d / %d", }, }, { name: "Parse MMMM DD, YYYY date from header", slideshow: fmt.Sprintf("---\ndate: %q\n", "MMMM DD, YYYY"), want: &meta.Meta{ Theme: "default", Author: user.Name, Date: time.Now().Format("January 02, 2006"), Paging: "Slide %d / %d", }, }, { name: "Fallback to default if no date provided", slideshow: "\n# Header Slide\n > Subtitle\n", want: &meta.Meta{ Theme: "default", Author: user.Name, Date: date, Paging: "Slide %d / %d", }, }, { name: "Parse paging from header", slideshow: fmt.Sprintf("---\npaging: %q\n", "%d of %d"), want: &meta.Meta{ Theme: "default", Author: user.Name, Date: date, Paging: "%d of %d", }, }, { name: "Fallback to default if no numebring provided", slideshow: "\n# Header Slide\n > Subtitle\n", want: &meta.Meta{ Theme: "default", Author: user.Name, Date: date, Paging: "Slide %d / %d", }, }, { name: "Fallback if first slide is valid yaml", slideshow: "---\n# Header Slide---\nContent\n", want: &meta.Meta{ Theme: "default", Author: user.Name, Date: date, Paging: "Slide %d / %d", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m := &meta.Meta{} got, hasMeta := m.Parse(tt.slideshow) if !hasMeta { assert.NotNil(t, got) } assert.Equal(t, tt.want, got) }) } } func TestNew(t *testing.T) { tests := []struct { name string want *meta.Meta }{ {name: "Create meta struct", want: &meta.Meta{}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, meta.New(), tt.want) }) } } func ExampleMeta_Parse() { header := ` --- theme: "dark" author: "Gopher" date: "Apr. 4, 2021" paging: "%d" --- ` // Parse the header from the markdown // file m, _ := meta.New().Parse(header) // Print the return theme // meta fmt.Println(m.Theme) fmt.Println(m.Author) fmt.Println(m.Date) fmt.Println(m.Paging) } ================================================ FILE: internal/model/model.go ================================================ package model import ( "bufio" _ "embed" "errors" "fmt" "io" "os" "strings" "time" "github.com/atotto/clipboard" "github.com/maaslalani/slides/internal/file" "github.com/maaslalani/slides/internal/navigation" "github.com/maaslalani/slides/internal/process" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/glamour" "github.com/maaslalani/slides/internal/code" "github.com/maaslalani/slides/internal/meta" "github.com/maaslalani/slides/styles" ) var ( //go:embed tutorial.md slidesTutorial []byte tabSpaces = strings.Repeat(" ", 4) ) const ( delimiter = "\n---\n" ) // Model represents the model of this presentation, which contains all the // state related to the current slides. type Model struct { Slides []string Page int Author string Date string Theme glamour.TermRendererOption Paging string FileName string viewport viewport.Model buffer string // VirtualText is used for additional information that is not part of the // original slides, it will be displayed on a slide and reset on page change VirtualText string Search navigation.Search } type fileWatchMsg struct{} var fileInfo os.FileInfo // Init initializes the model and begins watching the slides file for changes // if it exists. func (m Model) Init() tea.Cmd { if m.FileName == "" { return nil } fileInfo, _ = os.Stat(m.FileName) return fileWatchCmd() } func fileWatchCmd() tea.Cmd { return tea.Every(time.Second, func(t time.Time) tea.Msg { return fileWatchMsg{} }) } // Load loads all of the content and metadata for the presentation. func (m *Model) Load() error { var content string var err error if m.FileName != "" { content, err = readFile(m.FileName) } else { content, err = readStdin() } if err != nil { return err } content = strings.ReplaceAll(content, "\r", "") content = strings.TrimPrefix(content, strings.TrimPrefix(delimiter, "\n")) slides := strings.Split(content, delimiter) metaData, exists := meta.New().Parse(slides[0]) // If the user specifies a custom configuration options // skip the first "slide" since this is all configuration if exists && len(slides) > 1 { slides = slides[1:] } m.Slides = slides m.Author = metaData.Author m.Date = metaData.Date m.Paging = metaData.Paging if m.Theme == nil { m.Theme = styles.SelectTheme(metaData.Theme) } return nil } // Update updates the presentation model. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.viewport.Width = msg.Width m.viewport.Height = msg.Height return m, nil case tea.KeyMsg: keyPress := msg.String() if m.Search.Active { switch msg.Type { case tea.KeyEnter: // execute current buffer if m.Search.Query() != "" { m.Search.Execute(&m) } else { m.Search.Done() } // cancel search return m, nil case tea.KeyCtrlC, tea.KeyEscape: // quit command mode m.Search.SetQuery("") m.Search.Done() return m, nil } var cmd tea.Cmd m.Search.SearchTextInput, cmd = m.Search.SearchTextInput.Update(msg) return m, cmd } switch keyPress { case "/": // Begin search m.Search.Begin() m.Search.SearchTextInput.Focus() return m, nil case "ctrl+n": // Go to next occurrence m.Search.Execute(&m) case "ctrl+e": // Run code blocks blocks, err := code.Parse(m.Slides[m.Page]) if err != nil { // We couldn't parse the code block on the screen m.VirtualText = "\n" + err.Error() return m, nil } var outs []string for _, block := range blocks { res := code.Execute(block) outs = append(outs, res.Out) } m.VirtualText = strings.Join(outs, "\n") case "y": blocks, err := code.Parse(m.Slides[m.Page]) if err != nil { return m, nil } for _, b := range blocks { _ = clipboard.WriteAll(b.Code) } return m, nil case "ctrl+c", "q": return m, tea.Quit default: newState := navigation.Navigate(navigation.State{ Buffer: m.buffer, Page: m.Page, TotalSlides: len(m.Slides), }, keyPress) m.buffer = newState.Buffer m.SetPage(newState.Page) } case fileWatchMsg: newFileInfo, err := os.Stat(m.FileName) if err == nil && newFileInfo.ModTime() != fileInfo.ModTime() { fileInfo = newFileInfo _ = m.Load() if m.Page >= len(m.Slides) { m.Page = len(m.Slides) - 1 } } return m, fileWatchCmd() } return m, nil } // View renders the current slide in the presentation and the status bar which // contains the author, date, and pagination information. func (m Model) View() string { r, _ := glamour.NewTermRenderer(m.Theme, glamour.WithWordWrap(m.viewport.Width)) slide := m.Slides[m.Page] slide = code.HideComments(slide) slide, err := r.Render(slide) slide = strings.ReplaceAll(slide, "\t", tabSpaces) slide += m.VirtualText if err != nil { slide = fmt.Sprintf("Error: Could not render markdown! (%v)", err) } slide = styles.Slide.Render(slide) var left string if m.Search.Active { // render search bar left = m.Search.SearchTextInput.View() } else { // render author and date left = styles.Author.Render(m.Author) + styles.Date.Render(m.Date) } right := styles.Page.Render(m.paging()) status := styles.Status.Render(styles.JoinHorizontal(left, right, m.viewport.Width)) return styles.JoinVertical(slide, status, m.viewport.Height) } func (m *Model) paging() string { switch strings.Count(m.Paging, "%d") { case 2: return fmt.Sprintf(m.Paging, m.Page+1, len(m.Slides)) case 1: return fmt.Sprintf(m.Paging, m.Page+1) default: return m.Paging } } func readFile(path string) (string, error) { s, err := os.Stat(path) if err != nil { return "", errors.New("could not read file") } if s.IsDir() { return "", errors.New("can not read directory") } b, err := os.ReadFile(path) if err != nil { return "", err } content := string(b) // Pre-process slides if the file is executable to avoid // unintentional code execution when presenting slides if file.IsExecutable(s) { // Remove shebang if file has one if strings.HasPrefix(content, "#!") { content = strings.Join(strings.SplitN(content, "\n", 2)[1:], "\n") } content = process.Pre(content) } return content, err } func readStdin() (string, error) { stat, err := os.Stdin.Stat() if err != nil { return "", err } if stat.Mode()&os.ModeNamedPipe == 0 && stat.Size() == 0 { return string(slidesTutorial), nil } reader := bufio.NewReader(os.Stdin) var b strings.Builder for { r, _, err := reader.ReadRune() if err != nil && err == io.EOF { break } _, err = b.WriteRune(r) if err != nil { return "", err } } return b.String(), nil } // CurrentPage returns the current page the presentation is on. func (m *Model) CurrentPage() int { return m.Page } // SetPage sets which page the presentation should render. func (m *Model) SetPage(page int) { if m.Page == page { return } m.VirtualText = "" m.Page = page } // Pages returns all the slides in the presentation. func (m *Model) Pages() []string { return m.Slides } ================================================ FILE: internal/model/tutorial.md ================================================ # Welcome to Slides A terminal based presentation tool ## Everything is markdown In fact this entire presentation is a markdown file. Press `n` to go to the next slide. --- # Display Code ```go package main import "fmt" func main() { // You can show code in slides // Press ctrl+e to execute this code directly in slides fmt.Println("Tada!") } ``` --- # h1 You can use everything in markdown! * Like bulleted list * You know the deal 1. Numbered lists too ## h2 | Tables | Too | | ------ | ------ | | Even | Tables | ### h3 #### h4 ##### h5 ###### h6 --- # Graphs ``` digraph { rankdir = LR; a -> b; b -> c; } ``` ``` ┌───┐ ┌───┐ ┌───┐ │ a │ ──▶ │ b │ ──▶ │ c │ └───┘ └───┘ └───┘ ``` --- All you need to do is separate slides with triple dashes `---` on a separate line, like so: ```markdown # Slide 1 Some stuff --- # Slide 2 Some other stuff ``` ================================================ FILE: internal/navigation/navigation.go ================================================ package navigation import ( "strconv" ) type repeatableFunc func(slide, totalSlides int) int // State tracks the current buffer, page, and total number of slides type State struct { Buffer string Page int TotalSlides int } // Navigate receives the current State and keyPress, and returns the new State. func Navigate(state State, keyPress string) State { switch keyPress { case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9": newBuffer := keyPress if bufferIsNumeric(state.Buffer) { newBuffer = state.Buffer + keyPress } return State{ Buffer: newBuffer, Page: state.Page, TotalSlides: state.TotalSlides, } case "g": switch state.Buffer { case "g": return State{ Page: 0, TotalSlides: state.TotalSlides, } default: return State{ Buffer: "g", Page: state.Page, TotalSlides: state.TotalSlides, } } case "G": targetSlide := state.TotalSlides - 1 if bufferIsNumeric(state.Buffer) { targetSlide = navigateSlide(state.Buffer, state.TotalSlides) } return State{ Page: targetSlide, TotalSlides: state.TotalSlides, } case " ", "down", "j", "right", "l", "enter", "n", "pgdown": return State{ Page: navigateNext(state), TotalSlides: state.TotalSlides, } case "up", "k", "left", "h", "p", "pgup", "N": return State{ Page: navigatePrevious(state), TotalSlides: state.TotalSlides, } default: return State{ Page: state.Page, TotalSlides: state.TotalSlides, } } } func bufferIsNumeric(buffer string) bool { _, err := strconv.Atoi(buffer) return err == nil } func navigateNext(state State) int { return repeatableAction(func(slide, totalSlides int) int { if slide < totalSlides-1 { return slide + 1 } return totalSlides - 1 }, state) } func navigateSlide(buffer string, totalSlides int) int { destinationSlide, _ := strconv.Atoi(buffer) destinationSlide-- if destinationSlide > totalSlides-1 { return totalSlides - 1 } if destinationSlide < 0 { return 0 } return destinationSlide } func navigatePrevious(state State) int { return repeatableAction(func(slide, totalSlides int) int { if slide > 0 { return slide - 1 } return slide }, state) } func repeatableAction(fn repeatableFunc, state State) int { if !bufferIsNumeric(state.Buffer) { return fn(state.Page, state.TotalSlides) } repeat, _ := strconv.Atoi(state.Buffer) page := state.Page if repeat == 0 { // This is how behaviour works in Vim, so following principle of least astonishment. return fn(state.Page, state.TotalSlides) } for i := 0; i < repeat; i++ { page = fn(page, state.TotalSlides) } return page } ================================================ FILE: internal/navigation/navigation_test.go ================================================ package navigation import ( "strings" "testing" "github.com/stretchr/testify/assert" ) func TestNavigation(t *testing.T) { tests := []struct { keys string target int }{ {target: 0}, {keys: "l", target: 1}, {keys: "jjjjjjjjjj", target: 10}, {keys: "jjjjjjjjjjjjj", target: 10}, {keys: "G", target: 10}, {keys: "llgg", target: 0}, {keys: "2j", target: 2}, {keys: "0j", target: 1}, {keys: "-11G", target: 10}, {keys: "0G", target: 0}, {keys: "3G", target: 2}, {keys: "11G", target: 10}, {keys: "101G", target: 10}, {keys: "nnN", target: 1}, } for _, tt := range tests { t.Run(tt.keys, func(t *testing.T) { currentState := State{ Buffer: "", Page: 0, TotalSlides: 11, } for _, key := range strings.Split(tt.keys, "") { currentState = Navigate(currentState, key) } targetState := State{Page: tt.target, TotalSlides: 11} assert.Equal(t, targetState, currentState) }) } } ================================================ FILE: internal/navigation/search.go ================================================ package navigation import ( "regexp" "strings" "github.com/charmbracelet/bubbles/textinput" "github.com/maaslalani/slides/styles" ) // Model is an interface for models.model, so that cycle imports are avoided type Model interface { CurrentPage() int SetPage(page int) Pages() []string } // Search represents the current search type Search struct { // Active - Show search bar instead of author and date? // Store keystrokes in Query? Active bool // Query stores the current "search term" SearchTextInput textinput.Model } // NewSearch creates and returns a new search model with the default settings. func NewSearch() Search { ti := textinput.New() ti.Placeholder = "search" ti.Prompt = "/" ti.PromptStyle = styles.Search ti.TextStyle = styles.Search return Search{SearchTextInput: ti} } // Query returns the text input's value. func (s *Search) Query() string { return s.SearchTextInput.Value() } // SetQuery sets the text input's value func (s *Search) SetQuery(query string) { s.SearchTextInput.SetValue(query) } // Done marks the search as done, but does not delete the search buffer. This // is useful if, for example, you want to jump to the next result and you // therefore still need the buffer. func (s *Search) Done() { s.Active = false } // Begin a new search (deletes old buffer) func (s *Search) Begin() { s.Active = true s.SetQuery("") } // Execute search func (s *Search) Execute(m Model) { defer s.Done() expr := s.Query() if expr == "" { return } if strings.HasSuffix(expr, "/i") { expr = "(?i)" + expr[:len(expr)-2] } pattern, err := regexp.Compile(expr) if err != nil { return } check := func(i int) bool { content := m.Pages()[i] if len(pattern.FindAllStringSubmatch(content, 1)) != 0 { m.SetPage(i) return true } return false } // search from next slide to end for i := m.CurrentPage() + 1; i < len(m.Pages()); i++ { if check(i) { return } } // search from first slide to previous for i := 0; i < m.CurrentPage(); i++ { if check(i) { return } } } ================================================ FILE: internal/navigation/search_test.go ================================================ package navigation import ( "testing" ) type mockModel struct { slides []string page int } func (m *mockModel) CurrentPage() int { return m.page } func (m *mockModel) SetPage(page int) { m.page = page } func (m *mockModel) Pages() []string { return m.slides } func TestSearch(t *testing.T) { data := []string{ "hi", "first", "second", "third", "AbCdEfG", "abcdefg", "seconds", } type query struct { desc string query string expected int } // query -> expected page queries := []query{ {"basic 'first'", "first", 1}, {"basic 'abc'", "abc", 5}, {"basic 'abc' next occurrence", "abc", 5}, {"'abc' ignore case", "abc/i", 4}, {"'abc' ignore case", "abc/i", 5}, {"'abc' ignore case", "abc/i", 4}, {"next occurrence 1/2", "sec", 6}, {"next occurrence 2/2", "sec", 2}, {"regex", "a.c", 5}, {"regex next occurrence", "a.c", 5}, {"regex ignore case", "a.c/i", 4}, {"regex ignore case next occurrence", "a.c/i", 5}, } m := &mockModel{ slides: data, page: 0, } s := &Search{} for _, query := range queries { s.SetQuery(query.query) s.Execute(m) if m.CurrentPage() != query.expected { t.Errorf("[%s] expected page %d, got %d", query.desc, query.expected, m.CurrentPage()) } } } ================================================ FILE: internal/process/execute_test.go ================================================ package process import "testing" func TestExecute(t *testing.T) { tt := []struct { block Block want string }{ { block: Block{ Command: "cat", Input: "Hello, world!", }, want: "Hello, world!", }, { block: Block{ Command: "sed -e s/Find/Replace/g", Input: "Find", }, want: "Replace", }, } for _, tc := range tt { t.Run(tc.want, func(t *testing.T) { if testing.Short() { t.SkipNow() } tc.block.Execute() got := tc.block.Output if tc.want != got { t.Fatalf("Invalid execution, want %s, got %s", tc.want, got) } }) } } ================================================ FILE: internal/process/process.go ================================================ package process import ( "fmt" "io" "os/exec" "regexp" "strings" ) // Block represents a pre-processable block which looks like the following: It // is delimited by ~~~ and contains a command to be run along with the input to // be passed, the entire block should be replaced with its command output // // ~~~sd block process // block // ~~~ type Block struct { Command string Input string Output string Raw string } // String implements the Stringer interface. func (b Block) String() string { return fmt.Sprintf("===\n%s\n%s\n%s\n===", b.Raw, b.Command, b.Input) } // ?: means non-capture group var reng = regexp.MustCompile("~~~(.+)\n(?:.|\n)*?\n~~~\\s?") var reg = regexp.MustCompile("(?s)~~~(.+?)\n(.*?)\n~~~\\s?") // Parse takes some markdown and returns blocks to be pre-processed func Parse(markdown string) []Block { var blocks []Block matches := reng.FindAllString(markdown, -1) for _, match := range matches { m := reg.FindStringSubmatch(match) blocks = append(blocks, Block{ Command: m[1], Input: m[2], Raw: strings.TrimSuffix(m[0], "\n"), }) } return blocks } // Execute takes performs the execution of the block's command // by passing in the block's input as stdin and sets the block output func (b *Block) Execute() { c := strings.Split(b.Command, " ") cmd := exec.Command(c[0], c[1:]...) stdin, err := cmd.StdinPipe() if err != nil { return } go func() { defer stdin.Close() _, _ = io.WriteString(stdin, b.Input) }() out, err := cmd.Output() if err != nil { return } b.Output = string(out) } // Pre processes the markdown content by executing the commands necessary and // returns the new processed content func Pre(content string) string { blocks := Parse(content) if len(blocks) <= 0 { return content } for _, block := range blocks { // TODO: Use goroutines, if possible block.Execute() // If multiple blocks have the same Raw value The will _likely_ have the // same Output value so we can probably optimize this // There may be edge cases, though, since block execution is not deterministic. content = strings.Replace(content, block.Raw, block.Output, 1) } return content } ================================================ FILE: internal/process/process_test.go ================================================ package process import ( "reflect" "testing" ) func TestParse(t *testing.T) { md := ` # Slide ~~~sd Replace Process Replace ~~~ Hello ~~~sd Replace Process Replace Multi-line input ~~~ ~~~echo -n World Hello ~~~ --- # Next Slide GraphViz Test ~~~graph-easy --as=boxart digraph { A -> B } ~~~ ` got := Parse(md) want := []Block{{ Command: "sd Replace Process", Input: "Replace", Raw: "~~~sd Replace Process\nReplace\n~~~", }, { Command: "sd Replace Process", Input: "Replace\nMulti-line input", Raw: "~~~sd Replace Process\nReplace\nMulti-line input\n~~~", }, { Command: "echo -n World", Input: "Hello", Raw: "~~~echo -n World\nHello\n~~~", }, { Command: "graph-easy --as=boxart", Input: "digraph {\n A -> B\n}", Raw: "~~~graph-easy --as=boxart\ndigraph {\n A -> B\n}\n~~~", }} if !reflect.DeepEqual(got, want) { t.Log(want) t.Log(got) t.Fatal("Did not parse blocks correctly") } } ================================================ FILE: internal/server/middleware.go ================================================ package server import ( "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" bm "github.com/charmbracelet/wish/bubbletea" "github.com/muesli/termenv" ) func slidesMiddleware(srv *Server) wish.Middleware { newProg := func(m tea.Model, opts ...tea.ProgramOption) *tea.Program { p := tea.NewProgram(m, opts...) return p } teaHandler := func(s ssh.Session) *tea.Program { _, _, active := s.Pty() if !active { fmt.Println("no active terminal, skipping") err := s.Exit(1) if err != nil { fmt.Println("Error exiting session") } return nil } return newProg(srv.presentation, tea.WithInput(s), tea.WithOutput(s), tea.WithAltScreen()) } return bm.MiddlewareWithProgramHandler(teaHandler, termenv.ANSI256) } ================================================ FILE: internal/server/server.go ================================================ package server import ( "context" "fmt" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" "github.com/maaslalani/slides/internal/model" ) // Server is the server for hosting this presentation. type Server struct { host string port int srv *ssh.Server presentation model.Model } // NewServer creates a new server. func NewServer(keyPath, host string, port int, presentation model.Model) (*Server, error) { s := &Server{ host: host, port: port, presentation: presentation, } srv, err := wish.NewServer( wish.WithHostKeyPath(keyPath), wish.WithAddress(fmt.Sprintf("%s:%d", host, port)), wish.WithMiddleware( slidesMiddleware(s), ), ) if err != nil { return nil, err } s.srv = srv return s, nil } // Start starts the ssh server. func (s *Server) Start() error { return s.srv.ListenAndServe() } // Shutdown shuts down the server. func (s *Server) Shutdown(ctx context.Context) error { return s.srv.Shutdown(ctx) } ================================================ FILE: main.go ================================================ package main import ( _ "embed" "os" "time" tea "github.com/charmbracelet/bubbletea" "github.com/maaslalani/slides/internal/cmd" "github.com/maaslalani/slides/internal/model" "github.com/maaslalani/slides/internal/navigation" "github.com/muesli/coral" ) var ( rootCmd = &coral.Command{ Use: "slides ", Short: "Terminal based presentation tool", Args: coral.ArbitraryArgs, RunE: func(cmd *coral.Command, args []string) error { var err error var fileName string if len(args) > 0 { fileName = args[0] } presentation := model.Model{ Page: 0, Date: time.Now().Format("2006-01-02"), FileName: fileName, Search: navigation.NewSearch(), } err = presentation.Load() if err != nil { return err } p := tea.NewProgram(presentation, tea.WithAltScreen()) _, err = p.Run() return err }, } ) func init() { rootCmd.AddCommand( cmd.ServeCmd, ) rootCmd.CompletionOptions.DisableDefaultCmd = true } func main() { if err := rootCmd.Execute(); err != nil { os.Exit(1) } } ================================================ FILE: snap/snapcraft.yaml ================================================ name: slides adopt-info: slides summary: Slides in your terminal. description: | Slides in your terminal. Usage: slides [flags] Flags: -h, --help help for slides license: MIT base: core22 grade: stable confinement: strict compression: lzo architectures: - build-on: amd64 - build-on: arm64 - build-on: armhf - build-on: ppc64el - build-on: s390x assumes: - command-chain apps: slides: command: bin/slides command-chain: - bin/homeishome-launch plugs: - home - ssh-keys - ssh-public-keys - network - network-bind parts: slides: source: https://github.com/maaslalani/slides source-type: git plugin: go build-snaps: - go override-pull: | snapcraftctl pull snapcraftctl set-version "$(git describe --tags | sed 's/^v//' | cut -d "-" -f1)" homeishome-launch: plugin: nil stage-snaps: - homeishome-launch ================================================ FILE: styles/styles.go ================================================ // Package styles implements the theming logic for slides package styles import ( _ "embed" "io" "net/http" "os" "strings" "github.com/charmbracelet/glamour" "github.com/charmbracelet/lipgloss" "github.com/muesli/termenv" ) const ( salmon = lipgloss.Color("#E8B4BC") ) var ( // Author is the style for the author text in the bottom-left corner of the // presentation. Author = lipgloss.NewStyle().Foreground(salmon).Align(lipgloss.Left).MarginLeft(2) // Date is the style for the date text in the bottom-left corner of the // presentation. Date = lipgloss.NewStyle().Faint(true).Align(lipgloss.Left).Margin(0, 1) // Page is the style for the pagination progress information text in the // bottom-right corner of the presentation. Page = lipgloss.NewStyle().Foreground(salmon).Align(lipgloss.Right).MarginRight(3) // Slide is the style for the slide. Slide = lipgloss.NewStyle().Padding(1) // Status is the style for the status bar at the bottom of the // presentation. Status = lipgloss.NewStyle().Padding(1) // Search is the style for the search input at the bottom-left corner of // the screen when searching is active. Search = lipgloss.NewStyle().Faint(true).Align(lipgloss.Left).MarginLeft(2) ) var ( // DefaultTheme is the default theme for the presentation. //go:embed theme.json DefaultTheme []byte ) // JoinHorizontal joins two strings horizontally and fills the space in-between. func JoinHorizontal(left, right string, width int) string { w := width - lipgloss.Width(right) return lipgloss.PlaceHorizontal(w, lipgloss.Left, left) + right } // JoinVertical joins two strings vertically and fills the space in-between. func JoinVertical(top, bottom string, height int) string { h := height - lipgloss.Height(bottom) return lipgloss.PlaceVertical(h, lipgloss.Top, top) + bottom } // SelectTheme picks a glamour style config based // on the theme provided in the markdown header func SelectTheme(theme string) glamour.TermRendererOption { switch theme { case "ascii": return glamour.WithStyles(glamour.ASCIIStyleConfig) case "light": return glamour.WithStyles(glamour.LightStyleConfig) case "dark": return glamour.WithStyles(glamour.DarkStyleConfig) case "notty": return glamour.WithStyles(glamour.NoTTYStyleConfig) default: var themeReader io.Reader var err error if strings.HasPrefix(theme, "http") { var resp *http.Response resp, err = http.Get(theme) if err != nil { return getDefaultTheme() } defer resp.Body.Close() themeReader = resp.Body } else { file, err := os.Open(theme) if err != nil { return getDefaultTheme() } defer file.Close() themeReader = file } bytes, err := io.ReadAll(themeReader) if err == nil { return glamour.WithStylesFromJSONBytes(bytes) } // Should log a warning so the user knows we failed to read their theme file return getDefaultTheme() } } func getDefaultTheme() glamour.TermRendererOption { if termenv.EnvNoColor() { return glamour.WithStyles(glamour.NoTTYStyleConfig) } if !termenv.HasDarkBackground() { return glamour.WithStyles(glamour.LightStyleConfig) } return glamour.WithStylesFromJSONBytes(DefaultTheme) } ================================================ FILE: styles/styles_test.go ================================================ package styles_test import ( "testing" "github.com/charmbracelet/glamour" "github.com/charmbracelet/glamour/ansi" "github.com/maaslalani/slides/styles" "github.com/stretchr/testify/assert" ) func TestSelectTheme(t *testing.T) { tests := []struct { name string theme string want ansi.StyleConfig wantErr bool }{ {name: "Select dark theme", theme: "dark", want: glamour.DarkStyleConfig, wantErr: false}, {name: "Select light theme", theme: "light", want: glamour.LightStyleConfig, wantErr: false}, {name: "Select ascii theme", theme: "ascii", want: glamour.ASCIIStyleConfig, wantErr: false}, {name: "Select notty theme", theme: "notty", want: glamour.NoTTYStyleConfig, wantErr: false}, {name: "Select theme with error", theme: "notty", want: glamour.DarkStyleConfig, wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Execute the theme selection and ensure // it returns a non-nil theme selectedTheme := styles.SelectTheme(tt.theme) assert.NotNil(t, selectedTheme) // Initialize renderers to compare output gotRenderer, _ := glamour.NewTermRenderer(selectedTheme) wantRenderer, _ := glamour.NewTermRenderer(glamour.WithStyles(tt.want)) // Render a the same string with two different // renderers gotOutput, _ := gotRenderer.Render(tt.name) wantOutput, _ := wantRenderer.Render(tt.name) // Inject exception to ensure a style that doesn't match // it's associated string if tt.wantErr { assert.NotEqual(t, wantOutput, gotOutput) return } // Ensure they both match assert.Equal(t, wantOutput, gotOutput) }) } } func TestSelectTheme_file(t *testing.T) { tests := []struct { name string theme string fileExists bool }{ {name: "Select custom theme json", theme: "./theme.json", fileExists: true}, {name: "Use an invalid filepath", theme: "./someinvalidfile.toml", fileExists: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Successfully return a theme if a file exists assert.NotNil(t, styles.SelectTheme(tt.theme)) // Successfully return a theme if a file doesn't exist if !tt.fileExists { assert.NotNil(t, styles.SelectTheme(tt.theme)) } }) } } ================================================ FILE: styles/theme.json ================================================ { "document": { "block_prefix": "\n", "block_suffix": "\n", "color": "252", "margin": 2 }, "block_quote": { "indent": 1, "indent_token": "│ " }, "paragraph": {}, "list": { "level_indent": 2 }, "heading": { "block_suffix": "\n", "color": "39", "bold": true }, "h1": { "prefix": "██ ", "suffix": " ", "color": "#9fc", "bold": true }, "h2": { "prefix": "▓▓▓ ", "color": "#1cc" }, "h3": { "prefix": "▒▒▒▒ ", "color": "#29c" }, "h4": { "color": "#559", "prefix": "░░░░░ " }, "h5": {}, "h6": {}, "text": {}, "strikethrough": { "crossed_out": true }, "emph": { "italic": true }, "strong": { "bold": true }, "hr": { "color": "240", "format": "\n--------\n" }, "item": { "block_prefix": "• " }, "enumeration": { "block_prefix": ". " }, "task": { "ticked": "[✓] ", "unticked": "[ ] " }, "link": { "color": "30", "underline": true }, "link_text": { "color": "35", "bold": true }, "image": { "color": "212", "underline": true }, "image_text": { "color": "243", "format": "Image: {{.text}} →" }, "code": { "prefix": " ", "suffix": " ", "color": "203", "background_color": "236" }, "code_block": { "theme": "dracula", "margin": 2 }, "table": { "center_separator": "┼", "column_separator": "│", "row_separator": "─" }, "definition_list": {}, "definition_term": {}, "definition_description": { "block_prefix": "\n🠶 " }, "html_block": {}, "html_span": {} }