init commit

This commit is contained in:
Yehoshua Adam Sandler
2026-02-05 15:33:07 -06:00
commit e70b629b4a
13 changed files with 1021 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
build/
*.exe
*.test
*.out
.DS_Store

24
Makefile Normal file
View File

@@ -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 ./...

120
README.md Normal file
View File

@@ -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 <file> [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 (H1H6)
- 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`.

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.1.0

81
clipboard/clipboard.go Normal file
View File

@@ -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
}

136
cmd/root.go Normal file
View File

@@ -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 <file>",
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 <file>)")
}
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
}

29
config/config.go Normal file
View File

@@ -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")
}

430
convert/rtf.go Normal file
View File

@@ -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
}

25
go.mod Normal file
View File

@@ -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
)

56
go.sum Normal file
View File

@@ -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=

57
install.sh Executable file
View File

@@ -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" <<EOF
[Desktop Entry]
Name=mach
Comment=Markdown to RTF clipboard converter
Exec=$INSTALL_DIR/mach %f
Terminal=true
Type=Application
Categories=Utility;TextTools;
MimeType=text/markdown;
EOF
echo "Created desktop entry at $DESKTOP_DIR/mach.desktop"
fi
echo "Done! Run 'mach -v' to verify."

7
main.go Normal file
View File

@@ -0,0 +1,7 @@
package main
import "mach/cmd"
func main() {
cmd.Execute()
}

50
test.md Normal file
View File

@@ -0,0 +1,50 @@
# Heading 1
## Heading 2
This is a paragraph with **bold text** and *italic text* and `inline code`.
### Lists
- Item one
- Item two
- Item three
1. First
2. Second
3. Third
### Code Block
```go
func main() {
fmt.Println("Hello, world!")
}
```
### Blockquote
> 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)