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