init commit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
build/
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
.DS_Store
|
||||||
24
Makefile
Normal file
24
Makefile
Normal 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
120
README.md
Normal 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 (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`.
|
||||||
81
clipboard/clipboard.go
Normal file
81
clipboard/clipboard.go
Normal 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
136
cmd/root.go
Normal 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
29
config/config.go
Normal 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
430
convert/rtf.go
Normal 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
25
go.mod
Normal 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
56
go.sum
Normal 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
57
install.sh
Executable 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
7
main.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "mach/cmd"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cmd.Execute()
|
||||||
|
}
|
||||||
50
test.md
Normal file
50
test.md
Normal 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~~
|
||||||
|
|
||||||
|

|
||||||
Reference in New Issue
Block a user