diff --git a/.gitignore b/.gitignore
index d17bd44..45487d0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,3 @@
/mdo
+*.md
+!README.md
diff --git a/README.md b/README.md
index 5c24904..0ea8ba0 100644
--- a/README.md
+++ b/README.md
@@ -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.
+
@@ -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
+```
+
+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
diff --git a/internal/app/app.go b/internal/app/app.go
index a608900..61f6e0e 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -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"
)
@@ -26,14 +29,20 @@ const (
shutdownTimeout = 20 * time.Second
)
-var errUsage = errors.New("uso: mdo ")
+var errUsage = errors.New("usage: mdo [-l|--live] ")
+
+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
}
@@ -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) {
@@ -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)
@@ -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:
@@ -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) {
diff --git a/internal/app/app_test.go b/internal/app/app_test.go
index 55a17c3..6c69489 100644
--- a/internal/app/app_test.go
+++ b/internal/app/app_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/internal/ngrok/ngrok.go b/internal/ngrok/ngrok.go
new file mode 100644
index 0000000..a0a32ff
--- /dev/null
+++ b/internal/ngrok/ngrok.go
@@ -0,0 +1,195 @@
+// Package ngrok starts a free ngrok HTTP tunnel for a local address.
+package ngrok
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ quickstartURL = "https://ngrok.com/docs/share-localhost/quickstart"
+ downloadURL = "https://ngrok.com/download"
+ signupURL = "https://dashboard.ngrok.com/signup"
+ startupTimeout = 15 * time.Second
+)
+
+var findExecutable = exec.LookPath
+
+// Lookup verifies that ngrok is available before a local server is started.
+func Lookup() (string, error) {
+ path, err := findExecutable("ngrok")
+ if err != nil {
+ return "", setupError("--live requires ngrok")
+ }
+ return path, nil
+}
+
+// Tunnel is a running ngrok agent process.
+type Tunnel struct {
+ url string
+ cmd *exec.Cmd
+ finished <-chan struct{}
+ mu sync.Mutex
+ waitErr error
+}
+
+// URL is the public HTTPS URL assigned by ngrok.
+func (t *Tunnel) URL() string { return t.url }
+
+// Done is closed when the ngrok process stops.
+func (t *Tunnel) Done() <-chan struct{} { return t.finished }
+
+// Err returns the process result after Done has been closed.
+func (t *Tunnel) Err() error {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.waitErr
+}
+
+// Close stops the agent and waits briefly for it to exit.
+func (t *Tunnel) Close() error {
+ if t == nil || t.cmd.Process == nil {
+ return nil
+ }
+ if err := t.cmd.Process.Signal(os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) {
+ return err
+ }
+ select {
+ case <-t.finished:
+ return t.Err()
+ case <-time.After(2 * time.Second):
+ if err := t.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
+ return err
+ }
+ <-t.finished
+ return t.Err()
+ }
+}
+
+// Start starts a free HTTP endpoint forwarding to target, such as 127.0.0.1:8080.
+func Start(binary, target string) (*Tunnel, error) {
+ cmd := exec.Command(binary, commandArgs(target)...)
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return nil, fmt.Errorf("prepare ngrok output: %w", err)
+ }
+ stderr, err := cmd.StderrPipe()
+ if err != nil {
+ return nil, fmt.Errorf("prepare ngrok errors: %w", err)
+ }
+ if err := cmd.Start(); err != nil {
+ return nil, fmt.Errorf("start ngrok: %w", err)
+ }
+
+ lines := make(chan string, 32)
+ var readers sync.WaitGroup
+ readers.Add(2)
+ go readLines(stdout, lines, &readers)
+ go readLines(stderr, lines, &readers)
+ go func() {
+ readers.Wait()
+ close(lines)
+ }()
+ finished := make(chan struct{})
+ tunnel := &Tunnel{cmd: cmd, finished: finished}
+ go func() {
+ tunnel.mu.Lock()
+ tunnel.waitErr = cmd.Wait()
+ tunnel.mu.Unlock()
+ close(finished)
+ }()
+
+ var output bytes.Buffer
+ timer := time.NewTimer(startupTimeout)
+ defer timer.Stop()
+ for {
+ select {
+ case line, ok := <-lines:
+ if !ok {
+ <-finished
+ return nil, startupError(tunnel.Err(), output.String())
+ }
+ appendOutput(&output, line)
+ if url := publicURL(line); url != "" {
+ tunnel.url = url
+ go drain(lines)
+ return tunnel, nil
+ }
+ case <-finished:
+ return nil, startupError(tunnel.Err(), output.String())
+ case <-timer.C:
+ _ = cmd.Process.Kill()
+ <-finished
+ return nil, startupError(errors.New("timed out waiting for a public URL"), output.String())
+ }
+ }
+}
+
+func commandArgs(target string) []string {
+ return []string{"http", target, "--log=stdout", "--log-format=json"}
+}
+
+func drain(lines <-chan string) {
+ for range lines {
+ }
+}
+
+func readLines(reader io.Reader, lines chan<- string, wg *sync.WaitGroup) {
+ defer wg.Done()
+ scanner := bufio.NewScanner(reader)
+ scanner.Buffer(make([]byte, 1024), 64<<10)
+ for scanner.Scan() {
+ lines <- scanner.Text()
+ }
+}
+
+func publicURL(line string) string {
+ var entry struct {
+ URL string `json:"url"`
+ }
+ if json.Unmarshal([]byte(line), &entry) != nil || !strings.HasPrefix(entry.URL, "https://") {
+ return ""
+ }
+ return strings.TrimSuffix(entry.URL, "/")
+}
+
+func appendOutput(output *bytes.Buffer, line string) {
+ const maxOutput = 4096
+ if output.Len() >= maxOutput {
+ return
+ }
+ line = strings.TrimSpace(line)
+ if line == "" {
+ return
+ }
+ remaining := maxOutput - output.Len()
+ if len(line) > remaining {
+ line = line[:remaining]
+ }
+ output.WriteString(line)
+ output.WriteByte('\n')
+}
+
+func startupError(err error, output string) error {
+ message := "ngrok could not start"
+ if err != nil {
+ message += ": " + err.Error()
+ }
+ if output != "" {
+ message += "\nngrok output: " + strings.TrimSpace(output)
+ }
+ return setupError(message)
+}
+
+func setupError(message string) error {
+ return fmt.Errorf("%s\n1. Create an account: %s\n2. Install ngrok: %s\n3. Configure it: ngrok config add-authtoken \nQuickstart: %s", message, signupURL, downloadURL, quickstartURL)
+}
diff --git a/internal/ngrok/ngrok_test.go b/internal/ngrok/ngrok_test.go
new file mode 100644
index 0000000..1e71517
--- /dev/null
+++ b/internal/ngrok/ngrok_test.go
@@ -0,0 +1,64 @@
+package ngrok
+
+import (
+ "errors"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestPublicURL(t *testing.T) {
+ tests := []struct {
+ line string
+ want string
+ }{
+ {`{"url":"https://example.ngrok-free.app"}`, "https://example.ngrok-free.app"},
+ {`{"url":"https://example.ngrok-free.app/"}`, "https://example.ngrok-free.app"},
+ {`{"url":"http://example.ngrok-free.app"}`, ""},
+ {`not json`, ""},
+ }
+ for _, tt := range tests {
+ if got := publicURL(tt.line); got != tt.want {
+ t.Errorf("publicURL(%q) = %q, want %q", tt.line, got, tt.want)
+ }
+ }
+}
+
+func TestCommandArgsUseOnlyFreeHTTPTunnelOptions(t *testing.T) {
+ want := []string{"http", "127.0.0.1:8080", "--log=stdout", "--log-format=json"}
+ if got := commandArgs("127.0.0.1:8080"); !reflect.DeepEqual(got, want) {
+ t.Errorf("commandArgs() = %q, want %q", got, want)
+ }
+}
+
+func TestStartupErrorIncludesConfigurationHelp(t *testing.T) {
+ err := startupError(nil, "authtoken is required")
+ message := err.Error()
+ for _, want := range []string{
+ "ngrok could not start",
+ signupURL,
+ downloadURL,
+ "ngrok config add-authtoken ",
+ quickstartURL,
+ } {
+ if !strings.Contains(message, want) {
+ t.Errorf("startupError() = %q, want %q", message, want)
+ }
+ }
+}
+
+func TestLookupExplainsHowToSetUpNgrok(t *testing.T) {
+ original := findExecutable
+ t.Cleanup(func() { findExecutable = original })
+ findExecutable = func(string) (string, error) { return "", errors.New("not found") }
+
+ _, err := Lookup()
+ if err == nil {
+ t.Fatal("Lookup() error = nil, want setup instructions")
+ }
+ for _, want := range []string{signupURL, downloadURL, quickstartURL, "ngrok config add-authtoken "} {
+ if !strings.Contains(err.Error(), want) {
+ t.Errorf("Lookup() error = %q, want %q", err, want)
+ }
+ }
+}
diff --git a/internal/web/embed_test.go b/internal/web/embed_test.go
index 2d5eb14..44d27fd 100644
--- a/internal/web/embed_test.go
+++ b/internal/web/embed_test.go
@@ -20,6 +20,7 @@ func TestPageIncludesDocumentAndEmbeddedAssets(t *testing.T) {
for _, expected := range []string{
`README <test>.md`, `Hello
`,
+ ``,
`globalThis["mermaid"]`, `const token = "abc123"`,
`fetch(`, `enableDiagramZoom`, `Aumentar zoom`, `requestFullscreen`,
`Navegação do diagrama`, `Mover diagrama para a esquerda`,
diff --git a/internal/web/page.html b/internal/web/page.html
index 999a88b..97600fb 100644
--- a/internal/web/page.html
+++ b/internal/web/page.html
@@ -4,6 +4,7 @@
+
{{.Title}} · mdo