Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
/mdo
*.md
!README.md
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Leitor descartável de Markdown para o terminal e navegador, escrito em Go.

> [!NOTE]
> This is a vibe-coded project: it is built experimentally with AI-assisted
> coding and human direction/review.

<img width="1882" height="939" alt="image" src="https://github.com/user-attachments/assets/eaa00322-2dbb-4cd4-945d-b716010ef50e" />
<img width="1889" height="925" alt="image" src="https://github.com/user-attachments/assets/fdfeb93b-1e7d-4bc2-99bc-f3dc020377bd" />

Expand All @@ -16,6 +20,30 @@ O `mdo` valida o arquivo, escolhe uma porta local livre, abre o navegador e ence

Somente arquivos com extensão exata `.md` são aceitos.

### Compartilhar temporariamente pela internet

Use `--live` (ou `-l`) para criar um link público temporário com ngrok:

```bash
mdo --live README.md
# ou
mdo -l README.md
```

Antes disso, instale o [ngrok Agent CLI](https://ngrok.com/download), crie ou
acesse sua conta e configure o authtoken uma vez:

```bash
ngrok config add-authtoken <YOUR_TOKEN>
```

O `mdo` imprime o link HTTPS que deve ser compartilhado e mantém o servidor e
o túnel ativos até você pressionar `Ctrl+C`. O modo usa apenas o domínio de
desenvolvimento automático do plano gratuito: não configura domínio reservado
nem recursos pagos. Consulte o [quickstart do ngrok](https://ngrok.com/docs/share-localhost/quickstart)
para obter o token e instruções de instalação. Sem `-l`/`--live`, o `mdo`
continua funcionando somente em localhost e não procura nem exige o ngrok.

## Recursos

- CommonMark e GitHub Flavored Markdown
Expand Down
87 changes: 79 additions & 8 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ import (
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"

"github.com/egomes/mdo/internal/browser"
"github.com/egomes/mdo/internal/document"
"github.com/egomes/mdo/internal/ngrok"
webassets "github.com/egomes/mdo/internal/web"
)

Expand All @@ -26,14 +29,20 @@ const (
shutdownTimeout = 20 * time.Second
)

var errUsage = errors.New("uso: mdo <arquivo.md>")
var errUsage = errors.New("usage: mdo [-l|--live] <file.md>")

type runOptions struct {
markdownPath string
live bool
}

func Run(args []string) error {
if len(args) != 1 {
return errUsage
options, err := parseArgs(args)
if err != nil {
return err
}

path, source, err := readMarkdown(args[0])
path, source, err := readMarkdown(options.markdownPath)
if err != nil {
return err
}
Expand All @@ -58,7 +67,34 @@ func Run(args []string) error {
return fmt.Errorf("montar página: %w", err)
}

return serveAndOpen(path, token, page)
var ngrokBinary string
if options.live {
ngrokBinary, err = ngrok.Lookup()
if err != nil {
return err
}
}

return serveAndOpen(path, token, page, options.live, ngrokBinary)
}

func parseArgs(args []string) (runOptions, error) {
var options runOptions
for _, arg := range args {
switch arg {
case "-l", "--live":
options.live = true
default:
if strings.HasPrefix(arg, "-") || options.markdownPath != "" {
return runOptions{}, errUsage
}
options.markdownPath = arg
}
}
if options.markdownPath == "" {
return runOptions{}, errUsage
}
return options, nil
}

func readMarkdown(input string) (string, []byte, error) {
Expand Down Expand Up @@ -97,7 +133,7 @@ func readMarkdown(input string) (string, []byte, error) {
return path, data, nil
}

func serveAndOpen(markdownPath, token string, page []byte) error {
func serveAndOpen(markdownPath, token string, page []byte, live bool, ngrokBinary string) error {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return fmt.Errorf("abrir porta local: %w", err)
Expand Down Expand Up @@ -137,12 +173,24 @@ func serveAndOpen(markdownPath, token string, page []byte) error {
}
serveErr := make(chan error, 1)
go func() { serveErr <- server.Serve(listener) }()
defer shutdownServer(server)

url := "http://" + listener.Addr().String() + basePath
var tunnel *ngrok.Tunnel
if live {
tunnel, err = ngrok.Start(ngrokBinary, listener.Addr().String())
if err != nil {
return err
}
defer tunnel.Close()
fmt.Printf("Live URL: %s%s\nPress Ctrl+C to stop sharing.\n", tunnel.URL(), basePath)
}
if err := browser.Open(url); err != nil {
_ = server.Close()
return fmt.Errorf("abrir navegador (%s): %w", url, err)
}
if live {
return waitForLiveServer(serveErr, tunnel)
}

select {
case <-ready:
Expand All @@ -153,12 +201,35 @@ func serveAndOpen(markdownPath, token string, page []byte) error {
case <-time.After(shutdownTimeout):
}

return nil
}

func waitForLiveServer(serveErr <-chan error, tunnel *ngrok.Tunnel) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
select {
case <-ctx.Done():
return nil
case err := <-serveErr:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("local server: %w", err)
}
return nil
case <-tunnel.Done():
err := tunnel.Err()
if err != nil {
return fmt.Errorf("ngrok stopped: %w", err)
}
return errors.New("ngrok stopped unexpectedly")
}
}

func shutdownServer(server *http.Server) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
_ = server.Close()
}
return nil
}

func setSecurityHeaders(w http.ResponseWriter) {
Expand Down
32 changes: 31 additions & 1 deletion internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,39 @@ func TestReadMarkdownRejectsLargeFile(t *testing.T) {
}

func TestRunRequiresOneArgument(t *testing.T) {
for _, args := range [][]string{nil, {"one.md", "two.md"}} {
for _, args := range [][]string{nil, {"one.md", "two.md"}, {"--unknown", "one.md"}} {
if err := Run(args); err != errUsage {
t.Errorf("Run(%q) error = %v, want %v", args, err, errUsage)
}
}
}

func TestParseArgs(t *testing.T) {
tests := []struct {
name string
args []string
want runOptions
fail bool
}{
{name: "local", args: []string{"file.md"}, want: runOptions{markdownPath: "file.md"}},
{name: "short live flag", args: []string{"-l", "file.md"}, want: runOptions{markdownPath: "file.md", live: true}},
{name: "long live flag after file", args: []string{"file.md", "--live"}, want: runOptions{markdownPath: "file.md", live: true}},
{name: "missing file", args: []string{"--live"}, fail: true},
{name: "unknown flag", args: []string{"--share", "file.md"}, fail: true},
{name: "two files", args: []string{"one.md", "two.md"}, fail: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseArgs(tt.args)
if tt.fail {
if err != errUsage {
t.Fatalf("parseArgs(%q) error = %v, want %v", tt.args, err, errUsage)
}
return
}
if err != nil || got != tt.want {
t.Fatalf("parseArgs(%q) = %#v, %v; want %#v, nil", tt.args, got, err, tt.want)
}
})
}
}
Loading
Loading