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.
<p align="center">
<img src="./assets/slides-1.gif?raw=true" alt="Slides Presentation" />
</p>
### Installation
[](https://formulae.brew.sh/formula/slides)
[](https://snapcraft.io/slides)
[](https://aur.archlinux.org/packages/slides)
<details markdown="block">
<summary>Instructions</summary>
#### 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.
</details>
### 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 `<C-e>`,
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:
* <kbd>g</kbd> <kbd>g</kbd>
Go to the next slide with any of the following key sequences:
* <kbd>space</kbd>
* <kbd>right</kbd>
* <kbd>down</kbd>
* <kbd>enter</kbd>
* <kbd>n</kbd>
* <kbd>j</kbd>
* <kbd>l</kbd>
* <kbd>Page Down</kbd>
* number + any of the above (go forward n slides)
Go to the previous slide with any of the following key sequences:
* <kbd>left</kbd>
* <kbd>up</kbd>
* <kbd>p</kbd>
* <kbd>h</kbd>
* <kbd>k</kbd>
* <kbd>N</kbd>
* <kbd>Page Up</kbd>
* number + any of the above (go back n slides)
Go to a specific slide with the following key sequence:
* number + <kbd>G</kbd>
Go to the last slide with the following key:
* <kbd>G</kbd>
### Search
To quickly jump to the right slide, you can use the search function.
Press <kbd>/</kbd>, enter your search term and press <kbd>Enter</kbd>
(*The search term is interpreted as a regular expression. The `/i` flag causes case-insensitivity.*).
Press <kbd>ctrl+n</kbd> 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 <kbd>ctrl+e</kbd> 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/<test>.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:
<!-- Use comments in your markdown! -->
* `bash`
* `zsh`
* `fish`
* `elixir`
* `go`
* `javascript`
* `python`
* `ruby`
* `perl`
* `rust`
* `java`
* `cpp`
* `swift`
* `dart`
* `v`
<!-- * `secret` -->
---
### 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 <iostream>
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 <file.md>",
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(
"<file>", f.Name(),
// <name>: file name without extension and without path
"<name>", filepath.Base(strings.TrimSuffix(f.Name(), filepath.Ext(f.Name()))),
"<path>", 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 <file>, <name> and <path> 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 <file>, <name> and <path> 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: <name> file name (without
// extension), <file> file name, <path> 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", "<file>"}},
},
Zsh: {
Extension: "zsh",
Commands: cmds{{"zsh", "<file>"}},
},
Fish: {
Extension: "fish",
Commands: cmds{{"fish", "<file>"}},
},
Elixir: {
Extension: "exs",
Commands: cmds{{"elixir", "<file>"}},
},
Go: {
Extension: "go",
Commands: cmds{{"go", "run", "<file>"}},
},
Javascript: {
Extension: "js",
Commands: cmds{{"node", "<file>"}},
},
Lua: {
Extension: "lua",
Commands: cmds{{"lua", "<file>"}},
},
Ruby: {
Extension: "rb",
Commands: cmds{{"ruby", "<file>"}},
},
OCaml: {
Extension: "ml",
Commands: cmds{{"ocaml", "<file>"}},
},
Python: {
Extension: "py",
Commands: cmds{{"python", "<file>"}},
},
Perl: {
Extension: "pl",
Commands: cmds{{"perl", "<file>"}},
},
Rust: {
Extension: "rs",
Commands: cmds{
// compile code
{"rustc", "<file>", "-o", "<path>/<name>.run"},
// run compiled file
{"<path>/<name>.run"},
},
},
Java: {
Extension: "java",
Commands: cmds{{"java", "<file>"}},
},
Julia: {
Extension: "jl",
Commands: cmds{{"julia", "<file>"}},
},
Cpp: {
Extension: "cpp",
Commands: cmds{
{"g++", "-std=c++20", "-o", "<path>/<name>.run", "<file>"},
{"<path>/<name>.run"},
},
},
Swift: {
Extension: "swift",
Commands: cmds{{"swift", "<file>"}},
},
Dart: {
Extension: "dart",
Commands: cmds{{"dart", "<file>"}},
},
V: {
Extension: "v",
Commands: cmds{{"v", "run", "<file>"}},
},
Scala: {
Extension: "sc",
Commands: cmds{{"scala-cli", "run", "<file>"}},
},
Haskell: {
Extension: "hs",
Commands: cmds{{"runghc", "<file>"}},
},
}
================================================
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 <file.md>",
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 <file.md> [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": {}
}
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
SYMBOL INDEX (109 symbols across 24 files)
FILE: internal/cmd/serve.go
function init (line 87) | func init() {
FILE: internal/code/code.go
type Block (line 14) | type Block struct
type Result (line 20) | type Result struct
function Parse (line 38) | func Parse(markdown string) ([]Block, error) {
constant ExitCodeInternalError (line 65) | ExitCodeInternalError = -1
function Execute (line 69) | func Execute(code Block) Result {
FILE: internal/code/code_test.go
function TestParse (line 9) | func TestParse(t *testing.T) {
FILE: internal/code/comments.go
constant comment (line 8) | comment = "///"
function HideComments (line 13) | func HideComments(content string) string {
function RemoveComments (line 19) | func RemoveComments(content string) string {
FILE: internal/code/comments_test.go
function TestHidesComments (line 5) | func TestHidesComments(t *testing.T) {
function TestNoComments (line 23) | func TestNoComments(t *testing.T) {
function TestRemoveComments (line 42) | func TestRemoveComments(t *testing.T) {
FILE: internal/code/execute_test.go
function TestExecute (line 9) | func TestExecute(t *testing.T) {
FILE: internal/code/languages.go
type cmds (line 5) | type cmds
type Language (line 9) | type Language struct
constant Bash (line 19) | Bash = "bash"
constant Zsh (line 20) | Zsh = "zsh"
constant Fish (line 21) | Fish = "fish"
constant Elixir (line 22) | Elixir = "elixir"
constant Go (line 23) | Go = "go"
constant Javascript (line 24) | Javascript = "javascript"
constant Lua (line 25) | Lua = "lua"
constant OCaml (line 26) | OCaml = "ocaml"
constant Perl (line 27) | Perl = "perl"
constant Python (line 28) | Python = "python"
constant Ruby (line 29) | Ruby = "ruby"
constant Rust (line 30) | Rust = "rust"
constant Java (line 31) | Java = "java"
constant Julia (line 32) | Julia = "julia"
constant Cpp (line 33) | Cpp = "cpp"
constant Swift (line 34) | Swift = "swift"
constant Dart (line 35) | Dart = "dart"
constant V (line 36) | V = "v"
constant Scala (line 37) | Scala = "scala"
constant Haskell (line 38) | Haskell = "haskell"
FILE: internal/file/file.go
function Exists (line 13) | func Exists(filepath string) bool {
function IsExecutable (line 22) | func IsExecutable(s fs.FileInfo) bool {
FILE: internal/file/file_test.go
function TestExists (line 13) | func TestExists(t *testing.T) {
function TestIsExecutable (line 33) | func TestIsExecutable(t *testing.T) {
FILE: internal/meta/meta.go
type parsedMeta (line 17) | type parsedMeta struct
type Meta (line 26) | type Meta struct
method Parse (line 44) | func (m *Meta) Parse(header string) (*Meta, bool) {
function New (line 35) | func New() *Meta {
function defaultTheme (line 90) | func defaultTheme() string {
function defaultAuthor (line 98) | func defaultAuthor() string {
function defaultDate (line 107) | func defaultDate() string {
function defaultPaging (line 111) | func defaultPaging() string {
function parseDate (line 115) | func parseDate(value string) string {
FILE: internal/meta/meta_test.go
function TestMeta_ParseHeader (line 13) | func TestMeta_ParseHeader(t *testing.T) {
function TestNew (line 176) | func TestNew(t *testing.T) {
function ExampleMeta_Parse (line 190) | func ExampleMeta_Parse() {
FILE: internal/model/model.go
constant delimiter (line 33) | delimiter = "\n---\n"
type Model (line 38) | type Model struct
method Init (line 60) | func (m Model) Init() tea.Cmd {
method Load (line 75) | func (m *Model) Load() error {
method Update (line 113) | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
method View (line 206) | func (m Model) View() string {
method paging (line 232) | func (m *Model) paging() string {
method CurrentPage (line 299) | func (m *Model) CurrentPage() int {
method SetPage (line 304) | func (m *Model) SetPage(page int) {
method Pages (line 314) | func (m *Model) Pages() []string {
type fileWatchMsg (line 54) | type fileWatchMsg struct
function fileWatchCmd (line 68) | func fileWatchCmd() tea.Cmd {
function readFile (line 243) | func readFile(path string) (string, error) {
function readStdin (line 271) | func readStdin() (string, error) {
FILE: internal/navigation/navigation.go
type repeatableFunc (line 7) | type repeatableFunc
type State (line 10) | type State struct
function Navigate (line 17) | func Navigate(state State, keyPress string) State {
function bufferIsNumeric (line 73) | func bufferIsNumeric(buffer string) bool {
function navigateNext (line 78) | func navigateNext(state State) int {
function navigateSlide (line 88) | func navigateSlide(buffer string, totalSlides int) int {
function navigatePrevious (line 103) | func navigatePrevious(state State) int {
function repeatableAction (line 113) | func repeatableAction(fn repeatableFunc, state State) int {
FILE: internal/navigation/navigation_test.go
function TestNavigation (line 10) | func TestNavigation(t *testing.T) {
FILE: internal/navigation/search.go
type Model (line 12) | type Model interface
type Search (line 19) | type Search struct
method Query (line 38) | func (s *Search) Query() string {
method SetQuery (line 43) | func (s *Search) SetQuery(query string) {
method Done (line 50) | func (s *Search) Done() {
method Begin (line 55) | func (s *Search) Begin() {
method Execute (line 61) | func (s *Search) Execute(m Model) {
function NewSearch (line 28) | func NewSearch() Search {
FILE: internal/navigation/search_test.go
type mockModel (line 7) | type mockModel struct
method CurrentPage (line 12) | func (m *mockModel) CurrentPage() int {
method SetPage (line 16) | func (m *mockModel) SetPage(page int) {
method Pages (line 20) | func (m *mockModel) Pages() []string {
function TestSearch (line 24) | func TestSearch(t *testing.T) {
FILE: internal/process/execute_test.go
function TestExecute (line 5) | func TestExecute(t *testing.T) {
FILE: internal/process/process.go
type Block (line 18) | type Block struct
method String (line 26) | func (b Block) String() string {
method Execute (line 51) | func (b *Block) Execute() {
function Parse (line 35) | func Parse(markdown string) []Block {
function Pre (line 74) | func Pre(content string) string {
FILE: internal/process/process_test.go
function TestParse (line 8) | func TestParse(t *testing.T) {
FILE: internal/server/middleware.go
function slidesMiddleware (line 13) | func slidesMiddleware(srv *Server) wish.Middleware {
FILE: internal/server/server.go
type Server (line 13) | type Server struct
method Start (line 42) | func (s *Server) Start() error {
method Shutdown (line 47) | func (s *Server) Shutdown(ctx context.Context) error {
function NewServer (line 21) | func NewServer(keyPath, host string, port int, presentation model.Model)...
FILE: main.go
function init (line 46) | func init() {
function main (line 53) | func main() {
FILE: styles/styles.go
constant salmon (line 17) | salmon = lipgloss.Color("#E8B4BC")
function JoinHorizontal (line 47) | func JoinHorizontal(left, right string, width int) string {
function JoinVertical (line 53) | func JoinVertical(top, bottom string, height int) string {
function SelectTheme (line 60) | func SelectTheme(theme string) glamour.TermRendererOption {
function getDefaultTheme (line 98) | func getDefaultTheme() glamour.TermRendererOption {
FILE: styles/styles_test.go
function TestSelectTheme (line 12) | func TestSelectTheme(t *testing.T) {
function TestSelectTheme_file (line 54) | func TestSelectTheme_file(t *testing.T) {
Condensed preview — 53 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (95K chars).
[
{
"path": ".github/FUNDING.yml",
"chars": 19,
"preview": "github: maaslalani\n"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 834,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the b"
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.md",
"chars": 595,
"preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your fea"
},
{
"path": ".github/dependabot.yml",
"chars": 108,
"preview": "version: 2\nupdates:\n - package-ecosystem: \"gomod\"\n directory: \"/\"\n schedule:\n interval: \"daily\"\n"
},
{
"path": ".github/pull_request_template.md",
"chars": 43,
"preview": "Fixes #...\n\n### Changes Introduced\n- \n- \n-\n"
},
{
"path": ".github/workflows/goreleaser.yml",
"chars": 493,
"preview": "name: goreleaser\n\non:\n push:\n tags:\n - '*'\n\njobs:\n goreleaser:\n runs-on: ubuntu-latest\n steps:\n - u"
},
{
"path": ".github/workflows/test.yml",
"chars": 355,
"preview": "name: test\n\non: [ push, pull_request ]\n\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checko"
},
{
"path": ".gitignore",
"chars": 48,
"preview": "/slides\n.idea\nslides_ed25519\nslides_ed25519.pub\n"
},
{
"path": "CODE_OF_CONDUCT.md",
"chars": 35,
"preview": "# Code of Conduct\n\nBe nice please!\n"
},
{
"path": "CONTRIBUTING.md",
"chars": 97,
"preview": "Take a look at the [Development Docs](./docs/development/README.md).\n\nPull requests are welcome!\n"
},
{
"path": "LICENSE",
"chars": 1068,
"preview": "MIT License\n\nCopyright (c) 2021 Maas Lalani\n\nPermission is hereby granted, free of charge, to any person obtaining a cop"
},
{
"path": "Makefile",
"chars": 98,
"preview": "make:\n\tgo run main.go examples/slides.md\n\ntest:\n\tgo test ./... -short\n\nbuild:\n\tgo build -o slides\n"
},
{
"path": "README.md",
"chars": 7241,
"preview": "# Slides\n\nSlides in your terminal.\n\n<p align=\"center\">\n <img src=\"./assets/slides-1.gif?raw=true\" alt=\"Slides Presentat"
},
{
"path": "SECURITY.md",
"chars": 97,
"preview": "# Security Policy\n\n## Reporting a Vulnerability\n\nEmail [maas@lalani.dev](mailto:maas@lalani.dev)\n"
},
{
"path": "docs/development/README.md",
"chars": 811,
"preview": "# Development\n\nMake changes, and test them by running:\n```\nmake\n```\n\nThis will run `go run main.go examples/slides.md`, "
},
{
"path": "examples/ascii_slides.md",
"chars": 653,
"preview": "---\ntheme: ascii\n---\n\n# Welcome to Slides\nA terminal based presentation tool\n\n```go\npackage main\n\nimport \"fmt\"\n\nfunc mai"
},
{
"path": "examples/code_blocks.md",
"chars": 1996,
"preview": "# Code blocks\n\nSlides allows you to execute code blocks directly inside your slides!\n\nJust press `ctrl+e` and the result"
},
{
"path": "examples/custom_remote_theme.md",
"chars": 228,
"preview": "---\ntheme: https://github.com/maaslalani/slides/raw/main/styles/theme.json\n---\n\n# Slides\n\nThe theme of this slide is fet"
},
{
"path": "examples/custom_theme.md",
"chars": 112,
"preview": "---\ntheme: ./examples/theme.json\n---\n\n# Slides\n\nThe above title should be orange and be prefixed with `CUSTOM`.\n"
},
{
"path": "examples/import.md",
"chars": 82,
"preview": "This is just an example of how to import text from other files with\npreprocess.md\n"
},
{
"path": "examples/metadata.md",
"chars": 386,
"preview": "---\nauthor: Gopher\ndate: May 22, 2022\npaging: Page %d of %d\n---\n\n# Metadata Example\n\nCustomize the bottom information ba"
},
{
"path": "examples/preprocess.md",
"chars": 1377,
"preview": "# Slides\n\nYou can add a code block with three tildes (~) and write a command to run before displaying\nthe slides, the te"
},
{
"path": "examples/slides.md",
"chars": 814,
"preview": "# Welcome to Slides\nA terminal based presentation tool\n\n```go\npackage main\n\nimport \"fmt\"\n\nfunc main() {\n fmt.Println(\"W"
},
{
"path": "examples/theme.json",
"chars": 1640,
"preview": "{\n \"document\": {\n \"block_prefix\": \"\\n\",\n \"block_suffix\": \"\\n\",\n \"color\": \"252\",\n \"margin\": 2\n },\n \"block_"
},
{
"path": "go.mod",
"chars": 2422,
"preview": "module github.com/maaslalani/slides\n\ngo 1.22\n\nrequire (\n\tgithub.com/atotto/clipboard v0.1.4\n\tgithub.com/charmbracelet/bu"
},
{
"path": "go.sum",
"chars": 10218,
"preview": "github.com/alecthomas/assert/v2 v2.2.1 h1:XivOgYcduV98QCahG8T5XTezV5bylXe+lBxLG2K2ink=\ngithub.com/alecthomas/assert/v2 v"
},
{
"path": "internal/cmd/serve.go",
"chars": 2035,
"preview": "package cmd\n\nimport (\n\t\"context\"\n\t\"log\"\n\t\"os\"\n\t\"os/signal\"\n\t\"strconv\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/maaslalani/slides"
},
{
"path": "internal/code/code.go",
"chars": 3315,
"preview": "package code\n\nimport (\n\t\"errors\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n)\n\n// Block represents a"
},
{
"path": "internal/code/code_test.go",
"chars": 2033,
"preview": "package code_test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/maaslalani/slides/internal/code\"\n)\n\nfunc TestParse(t *testing.T) {\n"
},
{
"path": "internal/code/comments.go",
"chars": 545,
"preview": "package code\n\nimport (\n\t\"regexp\"\n\t\"strings\"\n)\n\nconst comment = \"///\"\n\nvar commentRegexp = regexp.MustCompile(\"(?m)[\\r\\n]"
},
{
"path": "internal/code/comments_test.go",
"chars": 1071,
"preview": "package code\n\nimport \"testing\"\n\nfunc TestHidesComments(t *testing.T) {\n\tcontent := `\n///package main\n///\n///import \"fmt\""
},
{
"path": "internal/code/execute_test.go",
"chars": 1326,
"preview": "package code_test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/maaslalani/slides/internal/code\"\n)\n\nfunc TestExecute(t *testing.T) "
},
{
"path": "internal/code/languages.go",
"chars": 2767,
"preview": "package code\n\n// cmds: Multiple commands; placeholders can be used\n// Placeholders <file>, <name> and <path> can be used"
},
{
"path": "internal/file/file.go",
"chars": 485,
"preview": "// Package file includes utility functions\n// for working with the filesystem\npackage file\n\nimport (\n\t\"io/fs\"\n\t\"os\"\n)\n\n/"
},
{
"path": "internal/file/file_test.go",
"chars": 1417,
"preview": "package file_test\n\nimport (\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/maaslalani/slides/internal/file\"\n\t\"github.com"
},
{
"path": "internal/meta/meta.go",
"chars": 2514,
"preview": "// Package meta implements markdown frontmatter parsing for simple\n// slides configuration\npackage meta\n\nimport (\n\t\"os\"\n"
},
{
"path": "internal/meta/meta_test.go",
"chars": 4606,
"preview": "package meta_test\n\nimport (\n\t\"fmt\"\n\t\"os/user\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/maaslalani/slides/internal/meta\"\n\t\"github"
},
{
"path": "internal/model/model.go",
"chars": 7188,
"preview": "package model\n\nimport (\n\t\"bufio\"\n\t_ \"embed\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/atotto/clipboa"
},
{
"path": "internal/model/tutorial.md",
"chars": 911,
"preview": "# Welcome to Slides\nA terminal based presentation tool\n\n## Everything is markdown\nIn fact this entire presentation is a "
},
{
"path": "internal/navigation/navigation.go",
"chars": 2717,
"preview": "package navigation\n\nimport (\n\t\"strconv\"\n)\n\ntype repeatableFunc func(slide, totalSlides int) int\n\n// State tracks the cur"
},
{
"path": "internal/navigation/navigation_test.go",
"chars": 963,
"preview": "package navigation\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestNavigation(t *test"
},
{
"path": "internal/navigation/search.go",
"chars": 2052,
"preview": "package navigation\n\nimport (\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/bubbles/textinput\"\n\t\"github.com/maaslalani"
},
{
"path": "internal/navigation/search_test.go",
"chars": 1264,
"preview": "package navigation\n\nimport (\n\t\"testing\"\n)\n\ntype mockModel struct {\n\tslides []string\n\tpage int\n}\n\nfunc (m *mockModel) C"
},
{
"path": "internal/process/execute_test.go",
"chars": 603,
"preview": "package process\n\nimport \"testing\"\n\nfunc TestExecute(t *testing.T) {\n\ttt := []struct {\n\t\tblock Block\n\t\twant string\n\t}{\n\t"
},
{
"path": "internal/process/process.go",
"chars": 2190,
"preview": "package process\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os/exec\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// Block represents a pre-processable block wh"
},
{
"path": "internal/process/process_test.go",
"chars": 962,
"preview": "package process\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc TestParse(t *testing.T) {\n\tmd := `\n# Slide\n\n~~~sd Replace Proces"
},
{
"path": "internal/server/middleware.go",
"chars": 799,
"preview": "package server\n\nimport (\n\t\"fmt\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/ssh\"\n\t\"github.com/"
},
{
"path": "internal/server/server.go",
"chars": 1007,
"preview": "package server\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/charmbracelet/ssh\"\n\t\"github.com/charmbracelet/wish\"\n\t\"github.co"
},
{
"path": "main.go",
"chars": 1069,
"preview": "package main\n\nimport (\n\t_ \"embed\"\n\t\"os\"\n\t\"time\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/maaslalani/slide"
},
{
"path": "snap/snapcraft.yaml",
"chars": 991,
"preview": "name: slides\nadopt-info: slides\nsummary: Slides in your terminal.\ndescription: |\n Slides in your terminal.\n \n Usage:\n"
},
{
"path": "styles/styles.go",
"chars": 3188,
"preview": "// Package styles implements the theming logic for slides\npackage styles\n\nimport (\n\t_ \"embed\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\""
},
{
"path": "styles/styles_test.go",
"chars": 2257,
"preview": "package styles_test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/charmbracelet/glamour\"\n\t\"github.com/charmbracelet/glamour/ansi\"\n\t"
},
{
"path": "styles/theme.json",
"chars": 1636,
"preview": "{\n \"document\": {\n \"block_prefix\": \"\\n\",\n \"block_suffix\": \"\\n\",\n \"color\": \"252\",\n \"margin\": 2\n },\n \"block_"
}
]
About this extraction
This page contains the full source code of the maaslalani/slides GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 53 files (81.8 KB), approximately 28.4k tokens, and a symbol index with 109 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.