blogssh/main.go
2025-06-01 20:41:24 +01:00

339 lines
8.0 KiB
Go

package main
import (
"context"
"errors"
"flag"
"net"
"os"
"os/signal"
"path/filepath"
"slices"
"strings"
"syscall"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wish/activeterm"
"github.com/charmbracelet/wish/bubbletea"
"github.com/charmbracelet/wish/logging"
"github.com/charmbracelet/x/term"
)
type errMsg error
type item struct {
title, desc string
index int // This is stupid but it'll work for now
}
func (i item) Title() string { return i.title }
func (i item) Description() string { return i.desc }
func (i item) FilterValue() string { return i.title }
// TODO: this should take in a parsed object, not just a string.
// That way we don't have to handle the parsing errors here.
func getPostWithoutMetaData(post string) (string, error) {
splitPost := strings.Split(post, "\n")
index := slices.Index(splitPost[1:], "+++")
if index == -1 {
return "", errors.New("Could not find close +++")
}
return strings.Join(splitPost[index+2:], "\n"), nil
}
type Page int
const (
FRONT Page = iota
POST_LIST
POST
)
type model struct {
renderer *lipgloss.Renderer
termRenderer *glamour.TermRenderer
list list.Model
allPosts []string
width int
height int
navModel navModel
frontPageContent string
frontPageModel postModel
postModel postModel
page Page
viewport viewport.Model
err error
}
func getViewPort(renderer *lipgloss.Renderer, width int, height int) viewport.Model {
vp := viewport.New(width, height)
vp.Style = renderer.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")).
PaddingRight(2)
return vp
}
func initialModel(renderer *lipgloss.Renderer, w int, h int) model {
posts, err := getAllPosts(filepath.Join(os.Getenv("BLOG_PATH"), "content/blog"))
if err != nil {
panic(err)
}
listItems := make([]list.Item, len(posts))
for i, post := range posts {
postInfo, err := getPostInfo(post)
if err != nil {
continue
}
listItems[i] = item{title: postInfo.Title, desc: postInfo.Date.String(), index: i}
}
list := list.New(listItems, list.NewDefaultDelegate(), w, h-4)
list.SetShowTitle(false)
// -1 to allow for the initial height of the nav model
vp := getViewPort(renderer, w, h-3)
// We need to adjust the width of the glamour render from our main width
// to account for a few things:
//
// * The viewport border width
// * The viewport padding
// * The viewport margins
// * The gutter glamour applies to the left side of the content
//
const glamourGutter = 2
glamourRenderWidth := w - vp.Style.GetHorizontalFrameSize() - glamourGutter
termRenderer, err := glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithWordWrap(glamourRenderWidth),
)
if err != nil {
panic(err)
}
frontPageContent, err := os.ReadFile(filepath.Join(os.Getenv("BLOG_PATH"), "content/_index.md"))
if err != nil {
panic(err)
}
processedFrontPage, err := processMarkdown(string(frontPageContent))
if err != nil {
panic(err)
}
frontPageModel, err := createMarkdownPostModel(processedFrontPage, termRenderer, vp)
if err != nil {
panic(err)
}
return model{
list: list,
allPosts: posts,
page: FRONT,
termRenderer: termRenderer,
viewport: vp,
frontPageModel: frontPageModel,
frontPageContent: processedFrontPage,
width: w,
height: h,
navModel: createNavModel(renderer, w),
}
}
func (m model) Init() tea.Cmd {
return tea.WindowSize()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "h":
m.page = FRONT
return m, nil
case "b":
m.page = POST_LIST
return m, nil
}
}
switch m.page {
case FRONT:
updatedFrontPageModel, cmd := m.frontPageModel.Update(msg)
m.frontPageModel = updatedFrontPageModel.(postModel)
if cmd != nil {
if _, ok := cmd().(childMessage); ok {
return m, tea.Quit
}
}
return m, cmd
case POST_LIST:
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case tea.KeyEnter.String():
m.page = POST
selectedItem := m.list.SelectedItem().(item)
processedPost, err := processMarkdown(m.allPosts[selectedItem.index])
if err != nil {
panic(err)
}
postModel, err := createMarkdownPostModel(processedPost, m.termRenderer, m.viewport)
if err != nil {
panic(err)
}
m.postModel = postModel
return m, nil
}
case tea.WindowSizeMsg:
// h, v := m.termRenderer.GetFrameSize()
// m.list.SetSize(msg.Width-h, msg.Height-v)
//
// m.height = msg.Height
// m.width = msg.Width
}
list, cmd := m.list.Update(msg)
m.list = list
return m, cmd
case POST:
updatedPostModel, cmd := m.postModel.Update(msg)
if cmd != nil {
if _, ok := cmd().(childMessage); ok {
m.page = POST_LIST
}
}
m.postModel = updatedPostModel.(postModel)
return m, nil
default:
panic("unreachable")
}
}
func (m model) View() string {
// TODO: better type safety would be to do this over the type.
switch m.page {
case FRONT:
return m.navModel.View() + "\n" + m.frontPageModel.View()
case POST_LIST:
return m.navModel.View() + m.renderer.NewStyle().Margin(1, 2).Render(m.list.View())
case POST:
return m.navModel.View() + "\n" + m.postModel.View()
default:
panic("unreachable")
}
}
const (
host = "0.0.0.0"
port = "23234"
)
func initServer() {
s, err := wish.NewServer(
ssh.AllocatePty(),
wish.WithAddress(net.JoinHostPort(host, port)),
wish.WithMiddleware(
bubbletea.Middleware(teaHandler),
activeterm.Middleware(), // Bubble Tea apps usually require a PTY.
logging.Middleware(),
),
)
if err != nil {
log.Error("Could not start server", "error", err)
}
done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
log.Info("Starting SSH server", "host", host, "port", port)
go func() {
if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
log.Error("Could not start server", "error", err)
done <- nil
}
}()
<-done
log.Info("Stopping SSH server")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer func() { cancel() }()
if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
log.Error("Could not stop server", "error", err)
}
}
func main() {
var isSsh bool
flag.BoolVar(&isSsh, "ssh", false, "Start an SSH server instead of the TUI application.")
flag.Parse()
if isSsh {
initServer()
} else {
w, h, err := term.GetSize(os.Stdout.Fd())
if err != nil {
panic(err)
}
p := tea.NewProgram(initialModel(lipgloss.DefaultRenderer(), w, h))
if _, err := p.Run(); err != nil {
log.Fatal(err)
}
}
}
// You can wire any Bubble Tea model up to the middleware with a function that
// handles the incoming ssh.Session. Here we just grab the terminal info and
// pass it to the new model. You can also return tea.ProgramOptions (such as
// tea.WithAltScreen) on a session by session basis.
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
// This should never fail, as we are using the activeterm middleware.
pty, _, _ := s.Pty()
// When running a Bubble Tea app over SSH, you shouldn't use the default
// lipgloss.NewStyle function.
// That function will use the color profile from the os.Stdin, which is the
// server, not the client.
// We provide a MakeRenderer function in the bubbletea middleware package,
// so you can easily get the correct renderer for the current session, and
// use it to create the styles.
// The recommended way to use these styles is to then pass them down to
// your Bubble Tea model.
renderer := bubbletea.MakeRenderer(s)
m := initialModel(renderer, pty.Window.Width, pty.Window.Height)
return m, []tea.ProgramOption{tea.WithAltScreen()}
}