From e70b629b4a848720190eeb85c74eb9fda1eee681 Mon Sep 17 00:00:00 2001 From: Yehoshua Adam Sandler Date: Thu, 5 Feb 2026 15:33:07 -0600 Subject: [PATCH] init commit --- .gitignore | 5 + Makefile | 24 +++ README.md | 120 ++++++++++++ VERSION | 1 + clipboard/clipboard.go | 81 ++++++++ cmd/root.go | 136 +++++++++++++ config/config.go | 29 +++ convert/rtf.go | 430 +++++++++++++++++++++++++++++++++++++++++ go.mod | 25 +++ go.sum | 56 ++++++ install.sh | 57 ++++++ main.go | 7 + test.md | 50 +++++ 13 files changed, 1021 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 VERSION create mode 100644 clipboard/clipboard.go create mode 100644 cmd/root.go create mode 100644 config/config.go create mode 100644 convert/rtf.go create mode 100644 go.mod create mode 100644 go.sum create mode 100755 install.sh create mode 100644 main.go create mode 100644 test.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..51f4c1b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +build/ +*.exe +*.test +*.out +.DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..240acf5 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +VERSION := $(shell cat VERSION) +BINARY := build/mach +LDFLAGS := -ldflags "-X mach/cmd.Version=$(VERSION)" + +.PHONY: build clean install test fmt vet + +build: + @mkdir -p build + go build $(LDFLAGS) -o $(BINARY) . + +clean: + rm -rf build + +install: build + cp $(BINARY) /usr/local/bin/mach + +test: + go test ./... + +fmt: + go fmt ./... + +vet: + go vet ./... diff --git a/README.md b/README.md new file mode 100644 index 0000000..907fb7b --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# mach + +A CLI tool that converts Markdown files to rich text and copies the result to your system clipboard. +Paste formatted text directly into emails, Slack messages, Google Docs, or any app that supports rich text. + +## Installation + +```bash +make build +# binary is at ./build/mach + +# or install to /usr/local/bin: +bash install.sh +``` + +## Usage + +``` +mach [flags] +``` + +Convert a Markdown file and copy to clipboard: + +```bash +mach notes.md +``` + +Also display the RTF output in the terminal: + +```bash +mach notes.md -d +``` + +Write an RTF file next to the input (e.g. `notes.md` → `notes.rtf`): + +```bash +mach notes.md -o +``` + +Write to a specific path: + +```bash +mach notes.md -o=/tmp/output.rtf +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `-i` | Input format (default: `md`) | +| `-t` | Output format (default: `rtf`) | +| `-o` | Output to file; `-o` derives path from input, `-o=path` writes to path | +| `-d` | Display converted text in terminal | +| `-v` | Print version | + +### Config + +Optional config file at `~/.config/mach/config.json`: + +```json +{ + "input_type": "md", + "output_type": "rtf" +} +``` + +Flag values override config values. + +## Supported Markdown + +- Headings (H1–H6) +- Bold, italic, strikethrough +- Inline code and fenced code blocks +- Ordered and unordered lists +- Task lists (`- [x]` / `- [ ]`) +- Blockquotes +- Links (rendered as clickable hyperlinks) +- Tables (GFM) +- Horizontal rules +- Images (rendered as `[image: alt text]` placeholder) + +## Technical Details + +### Conversion Pipeline + +mach uses [goldmark](https://github.com/yuin/goldmark) to parse Markdown into an AST, then walks the tree with a custom renderer that emits RTF directly. There is no intermediate HTML-to-RTF conversion and no dependency on external tools like `textutil` or `pandoc`. The RTF renderer is pure Go. + +For clipboard, a second pass converts the Markdown to HTML using goldmark's built-in HTML renderer. Both formats are placed on the clipboard so that every app gets the type it expects. + +### Dependencies + +- [goldmark](https://github.com/yuin/goldmark) — Markdown parser with GFM extensions +- [cobra](https://github.com/spf13/cobra) — CLI framework +- [viper](https://github.com/spf13/viper) — Config file management + +### Clipboard: OS Differences + +The clipboard is the only OS-specific code in the project. No build tags are used; the binary is cross-platform via a `runtime.GOOS` switch. + +**macOS** — Uses a small Swift snippet executed via `swift -` that writes to `NSPasteboard`. Three pasteboard types are set: + +| Type | Purpose | +|------|---------| +| `public.html` | Web apps (Slack, Gmail, Google Docs) | +| `public.rtf` | Native apps (TextEdit, Word, Pages) | +| `public.utf8-plain-text` | Plain text fallback (required by Electron apps like Slack) | + +`pbcopy` is not used because it can only set plain text — it has no way to specify the pasteboard type. + +**Linux** — Uses `xclip` to set clipboard content with `text/html` MIME type. `xclip` must be installed (`sudo apt install xclip`). + +### Build + +Version is stored in the `VERSION` file and injected at build time via ldflags: + +``` +go build -ldflags "-X mach/cmd.Version=$(cat VERSION)" -o build/mach . +``` + +The Makefile handles this automatically with `make build`. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/clipboard/clipboard.go b/clipboard/clipboard.go new file mode 100644 index 0000000..5926e0e --- /dev/null +++ b/clipboard/clipboard.go @@ -0,0 +1,81 @@ +package clipboard + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "runtime" +) + +// Copy places RTF and HTML onto the system clipboard. +// HTML is needed for web apps (Slack, Gmail); RTF for native apps (TextEdit, Word). +func Copy(rtf, html []byte) error { + switch runtime.GOOS { + case "darwin": + return copyDarwin(rtf, html) + case "linux": + return copyLinux(rtf, html) + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } +} + +func copyDarwin(rtf, html []byte) error { + rtfFile, err := writeTempFile("mach-*.rtf", rtf) + if err != nil { + return err + } + defer os.Remove(rtfFile) + + htmlFile, err := writeTempFile("mach-*.html", html) + if err != nil { + return err + } + defer os.Remove(htmlFile) + + // Use Swift to set public.rtf, public.html, and public.utf8-plain-text + // on NSPasteboard. All three are needed: + // - public.html → web apps (Slack, Gmail, Google Docs) + // - public.rtf → native apps (TextEdit, Word, Pages) + // - public.string → plain text fallback (Slack/Electron requires this) + swift := fmt.Sprintf(` +import AppKit +let rtfData = try! Data(contentsOf: URL(fileURLWithPath: "%s")) +let htmlData = try! Data(contentsOf: URL(fileURLWithPath: "%s")) +let htmlString = String(data: htmlData, encoding: .utf8) ?? "" +let pb = NSPasteboard.general +pb.clearContents() +pb.setData(rtfData, forType: .rtf) +pb.setData(htmlData, forType: .html) +pb.setString(htmlString, forType: .string) +`, rtfFile, htmlFile) + + cmd := exec.Command("swift", "-") + cmd.Stdin = bytes.NewReader([]byte(swift)) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("clipboard write failed: %s: %w", string(out), err) + } + return nil +} + +func copyLinux(rtf, html []byte) error { + // xclip can only set one target at a time; prefer HTML for broader compat + cmd := exec.Command("xclip", "-selection", "clipboard", "-t", "text/html", "-i") + cmd.Stdin = bytes.NewReader(html) + return cmd.Run() +} + +func writeTempFile(pattern string, data []byte) (string, error) { + f, err := os.CreateTemp("", pattern) + if err != nil { + return "", fmt.Errorf("failed to create temp file: %w", err) + } + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(f.Name()) + return "", fmt.Errorf("failed to write temp file: %w", err) + } + f.Close() + return f.Name(), nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..d1eaa5b --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,136 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "mach/clipboard" + "mach/config" + "mach/convert" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var Version = "dev" + +var ( + inputType string + outputType string + outputPath string + display bool +) + +var rootCmd = &cobra.Command{ + Use: "mach ", + Short: "Convert Markdown to Rich Text and copy to clipboard", + Long: "mach reads a Markdown file, converts it to RTF, and copies the result to the system clipboard.", + Args: cobra.RangeArgs(0, 1), + RunE: runMach, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func init() { + cobra.OnInitialize(config.Init) + + rootCmd.Flags().StringVarP(&inputType, "input", "i", "", "input format (default from config or \"md\")") + rootCmd.Flags().StringVarP(&outputType, "type", "t", "", "output format (default from config or \"rtf\")") + rootCmd.Flags().StringVarP(&outputPath, "output", "o", "", "output to file; use -o or -o=path") + rootCmd.Flags().BoolVarP(&display, "display", "d", false, "display converted text in terminal") + rootCmd.Flags().BoolP("version", "v", false, "print version and exit") + + // Allow -o without a value (derives path from input file) + rootCmd.Flags().Lookup("output").NoOptDefVal = " " +} + +func runMach(cmd *cobra.Command, args []string) error { + // Handle version flag + if v, _ := cmd.Flags().GetBool("version"); v { + fmt.Printf("mach %s\n", Version) + return nil + } + + if len(args) == 0 { + return fmt.Errorf("no input file specified (use mach )") + } + + inputFile := args[0] + + // Resolve input/output types: CLI flag > config > default + inType := resolveType(inputType, "input_type", "md") + outType := resolveType(outputType, "output_type", "rtf") + _ = inType // reserved for future input format support + + if outType != "rtf" { + return fmt.Errorf("unsupported output type: %s (only \"rtf\" is currently supported)", outType) + } + + // Read input file + source, err := os.ReadFile(inputFile) + if err != nil { + return fmt.Errorf("cannot read file: %w", err) + } + + // Convert to RTF (for file output and display) + rtf, err := convert.Convert(source) + if err != nil { + return fmt.Errorf("conversion error: %w", err) + } + + // Convert to HTML (for clipboard — web apps like Slack/Gmail need text/html) + html, err := convert.ConvertToHTML(source) + if err != nil { + return fmt.Errorf("html conversion error: %w", err) + } + + // Display if requested + if display { + fmt.Println(string(rtf)) + } + + // Write to file if -o was given + if cmd.Flags().Changed("output") { + outPath := outputPath + if strings.TrimSpace(outPath) == "" { + outPath = deriveOutputPath(inputFile, outType) + } + if err := os.WriteFile(outPath, rtf, 0644); err != nil { + return fmt.Errorf("cannot write output file: %w", err) + } + fmt.Printf("wrote %s\n", outPath) + } + + // Copy to clipboard (both RTF for native apps, HTML for web apps) + if err := clipboard.Copy(rtf, html); err != nil { + return fmt.Errorf("clipboard error: %w", err) + } + + if !display { + fmt.Println("copied to clipboard") + } + + return nil +} + +func resolveType(flag, configKey, fallback string) string { + if flag != "" { + return flag + } + if v := viper.GetString(configKey); v != "" { + return v + } + return fallback +} + +func deriveOutputPath(inputFile, outType string) string { + ext := filepath.Ext(inputFile) + base := strings.TrimSuffix(inputFile, ext) + return base + "." + outType +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..107f3c8 --- /dev/null +++ b/config/config.go @@ -0,0 +1,29 @@ +package config + +import ( + "os" + "path/filepath" + + "github.com/spf13/viper" +) + +func Init() { + viper.SetConfigName("config") + viper.SetConfigType("json") + + configDir := filepath.Join(userConfigDir(), "mach") + viper.AddConfigPath(configDir) + + viper.SetDefault("input_type", "md") + viper.SetDefault("output_type", "rtf") + + _ = viper.ReadInConfig() // config file is optional +} + +func userConfigDir() string { + if dir, err := os.UserConfigDir(); err == nil { + return dir + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config") +} diff --git a/convert/rtf.go b/convert/rtf.go new file mode 100644 index 0000000..00633d6 --- /dev/null +++ b/convert/rtf.go @@ -0,0 +1,430 @@ +package convert + +import ( + "bytes" + "fmt" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + extast "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/util" +) + +// RTFRenderer converts a Goldmark AST to RTF. +type RTFRenderer struct { + orderedIndex int // tracks current ordered list item number +} + +func NewRTFRenderer() *RTFRenderer { + return &RTFRenderer{} +} + +func (r *RTFRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + // Block nodes + reg.Register(ast.KindDocument, r.renderDocument) + reg.Register(ast.KindHeading, r.renderHeading) + reg.Register(ast.KindParagraph, r.renderParagraph) + reg.Register(ast.KindCodeBlock, r.renderCodeBlock) + reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock) + reg.Register(ast.KindBlockquote, r.renderBlockquote) + reg.Register(ast.KindList, r.renderList) + reg.Register(ast.KindListItem, r.renderListItem) + reg.Register(ast.KindThematicBreak, r.renderThematicBreak) + reg.Register(ast.KindHTMLBlock, r.renderRaw) + reg.Register(ast.KindTextBlock, r.renderTextBlock) + // Inline nodes + reg.Register(ast.KindText, r.renderText) + reg.Register(ast.KindString, r.renderString) + reg.Register(ast.KindEmphasis, r.renderEmphasis) + reg.Register(ast.KindCodeSpan, r.renderCodeSpan) + reg.Register(ast.KindLink, r.renderLink) + reg.Register(ast.KindAutoLink, r.renderAutoLink) + reg.Register(ast.KindImage, r.renderImage) + reg.Register(ast.KindRawHTML, r.renderRaw) + // GFM extensions + reg.Register(extast.KindStrikethrough, r.renderStrikethrough) + reg.Register(extast.KindTable, r.renderTable) + reg.Register(extast.KindTableHeader, r.renderTableHeader) + reg.Register(extast.KindTableRow, r.renderTableRow) + reg.Register(extast.KindTableCell, r.renderTableCell) + reg.Register(extast.KindTaskCheckBox, r.renderTaskCheckBox) +} + +// --- Block nodes --- + +func (r *RTFRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + _, _ = w.WriteString(`{\rtf1\ansi\deff0 {\fonttbl{\f0 Helvetica;}{\f1\fmodern Courier;}}`) + _, _ = w.WriteString("\n") + } else { + _, _ = w.WriteString("}") + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + h := node.(*ast.Heading) + if entering { + // Font sizes: H1=36, H2=32, H3=28, H4=24, H5=22, H6=20 + sizes := [7]int{0, 36, 32, 28, 24, 22, 20} + level := h.Level + if level < 1 || level > 6 { + level = 6 + } + fs := sizes[level] * 2 // RTF font size is in half-points + fmt.Fprintf(w, `{\pard\sb240\sa120\b\fs%d `, fs) + } else { + _, _ = w.WriteString(`\b0\par}`) + _, _ = w.WriteString("\n") + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderParagraph(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + _, _ = w.WriteString(`{\pard\sa120 `) + } else { + _, _ = w.WriteString(`\par}`) + _, _ = w.WriteString("\n") + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + _, _ = w.WriteString(`{\pard\f1\fs20 `) + r.writeLines(w, source, node) + _, _ = w.WriteString(`\par}`) + _, _ = w.WriteString("\n") + return ast.WalkSkipChildren, nil + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + _, _ = w.WriteString(`{\pard\f1\fs20 `) + r.writeLines(w, source, node) + _, _ = w.WriteString(`\par}`) + _, _ = w.WriteString("\n") + return ast.WalkSkipChildren, nil + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + _, _ = w.WriteString(`{\pard\li720\ri720\i `) + } else { + _, _ = w.WriteString(`\i0\par}`) + _, _ = w.WriteString("\n") + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + list := node.(*ast.List) + if list.IsOrdered() { + r.orderedIndex = int(list.Start) + } else { + r.orderedIndex = 0 + } + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + _, _ = w.WriteString(`{\pard\li360\fi-360 `) + parent := node.Parent() + if list, ok := parent.(*ast.List); ok && list.IsOrdered() { + fmt.Fprintf(w, `%d.\tab `, r.orderedIndex) + r.orderedIndex++ + } else { + _, _ = w.WriteString(`\bullet\tab `) + } + } else { + _, _ = w.WriteString(`\par}`) + _, _ = w.WriteString("\n") + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderThematicBreak(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + _, _ = w.WriteString(`{\pard\brdrb\brdrs\brdrw10\brsp20\par}`) + _, _ = w.WriteString("\n") + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderTextBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + // TextBlock is used inside blockquotes; just pass through. + if !entering { + _, _ = w.WriteString(`\line `) + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderRaw(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + // Skip raw HTML in RTF output + if entering { + return ast.WalkSkipChildren, nil + } + return ast.WalkContinue, nil +} + +// --- Inline nodes --- + +func (r *RTFRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + t := node.(*ast.Text) + writeRTFEscaped(w, t.Segment.Value(source)) + if t.SoftLineBreak() { + _, _ = w.WriteString(" ") + } + if t.HardLineBreak() { + _, _ = w.WriteString(`\line `) + } + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + s := node.(*ast.String) + writeRTFEscaped(w, s.Value) + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + e := node.(*ast.Emphasis) + if e.Level == 2 { + // Bold + if entering { + _, _ = w.WriteString(`{\b `) + } else { + _, _ = w.WriteString(`}`) + } + } else { + // Italic + if entering { + _, _ = w.WriteString(`{\i `) + } else { + _, _ = w.WriteString(`}`) + } + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderCodeSpan(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + _, _ = w.WriteString(`{\f1 `) + for c := node.FirstChild(); c != nil; c = c.NextSibling() { + if t, ok := c.(*ast.Text); ok { + writeRTFEscaped(w, t.Segment.Value(source)) + } + } + _, _ = w.WriteString(`}`) + return ast.WalkSkipChildren, nil + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + link := node.(*ast.Link) + if entering { + _, _ = w.WriteString(`{\field{\*\fldinst HYPERLINK "`) + writeRTFEscaped(w, link.Destination) + _, _ = w.WriteString(`"}{\fldrslt\ul `) + } else { + _, _ = w.WriteString(`}}`) + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + link := node.(*ast.AutoLink) + if entering { + url := link.URL(source) + _, _ = w.WriteString(`{\field{\*\fldinst HYPERLINK "`) + writeRTFEscaped(w, url) + _, _ = w.WriteString(`"}{\fldrslt\ul `) + writeRTFEscaped(w, url) + _, _ = w.WriteString(`}}`) + return ast.WalkSkipChildren, nil + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + _, _ = w.WriteString(`[image: `) + // Alt text is stored in child text nodes + for c := node.FirstChild(); c != nil; c = c.NextSibling() { + if t, ok := c.(*ast.Text); ok { + writeRTFEscaped(w, t.Segment.Value(source)) + } + } + _, _ = w.WriteString(`]`) + return ast.WalkSkipChildren, nil + } + return ast.WalkContinue, nil +} + +// --- GFM extension nodes --- + +func (r *RTFRenderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + _, _ = w.WriteString(`{\strike `) + } else { + _, _ = w.WriteString(`}`) + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + _, _ = w.WriteString("\n") + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderTableHeader(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + // TableHeader acts as a row: Table > TableHeader > TableCell + if entering { + _, _ = w.WriteString(`{\trowd\trgaph108 `) + cellCount := 0 + for c := node.FirstChild(); c != nil; c = c.NextSibling() { + cellCount++ + } + for i := 1; i <= cellCount; i++ { + fmt.Fprintf(w, `\cellx%d `, i*2880) + } + } else { + _, _ = w.WriteString(`\row}`) + _, _ = w.WriteString("\n") + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderTableRow(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + _, _ = w.WriteString(`{\trowd\trgaph108 `) + // Calculate cell boundaries + cellCount := 0 + for c := node.FirstChild(); c != nil; c = c.NextSibling() { + cellCount++ + } + for i := 1; i <= cellCount; i++ { + fmt.Fprintf(w, `\cellx%d `, i*2880) + } + } else { + _, _ = w.WriteString(`\row}`) + _, _ = w.WriteString("\n") + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderTableCell(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + isHeader := node.Parent().Kind() == extast.KindTableHeader + if entering { + _, _ = w.WriteString(`{\pard\intbl `) + if isHeader { + _, _ = w.WriteString(`\b `) + } + } else { + if isHeader { + _, _ = w.WriteString(`\b0`) + } + _, _ = w.WriteString(`\cell}`) + } + return ast.WalkContinue, nil +} + +func (r *RTFRenderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + cb := node.(*extast.TaskCheckBox) + if cb.IsChecked { + _, _ = w.WriteString(`[X] `) + } else { + _, _ = w.WriteString(`[ ] `) + } + } + return ast.WalkContinue, nil +} + +// --- Helpers --- + +func (r *RTFRenderer) writeLines(w util.BufWriter, source []byte, node ast.Node) { + lines := node.Lines() + for i := 0; i < lines.Len(); i++ { + line := lines.At(i) + value := line.Value(source) + // Replace newlines with RTF line breaks + for _, b := range value { + if b == '\n' { + _, _ = w.WriteString(`\line `) + } else { + writeRTFEscapedByte(w, b) + } + } + } +} + +func writeRTFEscaped(w util.BufWriter, data []byte) { + for _, b := range data { + writeRTFEscapedByte(w, b) + } +} + +func writeRTFEscapedByte(w util.BufWriter, b byte) { + switch b { + case '\\': + _, _ = w.WriteString(`\\`) + case '{': + _, _ = w.WriteString(`\{`) + case '}': + _, _ = w.WriteString(`\}`) + default: + _ = w.WriteByte(b) + } +} + +// Convert takes markdown bytes and returns RTF bytes. +func Convert(source []byte) ([]byte, error) { + md := goldmark.New( + goldmark.WithExtensions(extension.GFM), + goldmark.WithRenderer( + renderer.NewRenderer( + renderer.WithNodeRenderers( + util.Prioritized(NewRTFRenderer(), 100), + ), + ), + ), + ) + + var buf bytes.Buffer + if err := md.Convert(source, &buf); err != nil { + return nil, fmt.Errorf("conversion failed: %w", err) + } + return buf.Bytes(), nil +} + +// ConvertToHTML takes markdown bytes and returns HTML bytes. +// Used for clipboard on platforms where apps expect text/html (web apps like Slack, Gmail). +func ConvertToHTML(source []byte) ([]byte, error) { + md := goldmark.New( + goldmark.WithExtensions(extension.GFM), + ) + + var buf bytes.Buffer + if err := md.Convert(source, &buf); err != nil { + return nil, fmt.Errorf("html conversion failed: %w", err) + } + return buf.Bytes(), nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..32d3a9b --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module mach + +go 1.25.6 + +require ( + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 + github.com/yuin/goldmark v1.7.16 +) + +require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7f0c53f --- /dev/null +++ b/go.sum @@ -0,0 +1,56 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..2f3cc3a --- /dev/null +++ b/install.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +INSTALL_DIR="/usr/local/bin" +CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/mach" +BINARY="build/mach" + +OS="$(uname -s)" + +echo "Installing mach..." + +# Build if binary not present +if [ ! -f "$BINARY" ]; then + echo "Building..." + make build +fi + +# Install binary +echo "Copying binary to $INSTALL_DIR..." +sudo cp "$BINARY" "$INSTALL_DIR/mach" +sudo chmod +x "$INSTALL_DIR/mach" + +# Check PATH +if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then + echo "WARNING: $INSTALL_DIR is not in your PATH. Add it to your shell profile." +fi + +# Create config with defaults if not present +if [ ! -f "$CONFIG_DIR/config.json" ]; then + mkdir -p "$CONFIG_DIR" + cat > "$CONFIG_DIR/config.json" <<'EOF' +{ + "input_type": "md", + "output_type": "rtf" +} +EOF + echo "Created default config at $CONFIG_DIR/config.json" +fi + +# Linux: create .desktop entry +if [ "$OS" = "Linux" ]; then + DESKTOP_DIR="$HOME/.local/share/applications" + mkdir -p "$DESKTOP_DIR" + cat > "$DESKTOP_DIR/mach.desktop" < This is a blockquote +> with multiple lines. + +### Links + +Visit [Example](https://example.com) for more info. + +--- + +### Table + +| Name | Age | +|------|-----| +| Alice | 30 | +| Bob | 25 | + +### Task List + +- [x] Done +- [ ] Not done + +~~strikethrough text~~ + +![Alt text](image.png)