feat(release): beelloo v0.1

This commit is contained in:
2026-02-11 11:09:45 +01:00
parent 75ad1e7cee
commit 0dc9eda240
26 changed files with 1918 additions and 0 deletions

142
internal/cli/run.go Normal file
View File

@ -0,0 +1,142 @@
package cli
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"beelloo/internal/invoice"
"beelloo/internal/render"
"beelloo/internal/server"
)
func Run(args []string, out io.Writer, errOut io.Writer) int {
if len(args) == 0 {
usage(errOut)
return 2
}
switch args[0] {
case "new":
return runNew(args[1:], out, errOut)
case "emit":
return runBuild(args[1:], out, errOut)
case "build":
return runBuild(args[1:], out, errOut)
case "serve":
return runServe(args[1:], out, errOut)
case "-h", "--help", "help":
usage(out)
return 0
default:
fmt.Fprintf(errOut, "unknown command: %s\n", args[0])
usage(errOut)
return 2
}
}
func usage(w io.Writer) {
fmt.Fprintln(w, "Usage:")
fmt.Fprintln(w, " beelloo new <file.md>")
fmt.Fprintln(w, " beelloo build <file.md> [--css=style.css]")
fmt.Fprintln(w, " beelloo serve <file.md> [--addr=127.0.0.1:0] [--css=style.css]")
}
func runNew(args []string, out io.Writer, errOut io.Writer) int {
if len(args) != 1 {
fmt.Fprintln(errOut, "new requires a single file path")
return 2
}
path := args[0]
if err := os.WriteFile(path, []byte(invoice.ScaffoldMarkdown), 0644); err != nil {
fmt.Fprintf(errOut, "failed to write scaffold: %v\n", err)
return 1
}
fmt.Fprintf(out, "scaffold written to %s\n", path)
return 0
}
func runBuild(args []string, out io.Writer, errOut io.Writer) int {
if len(args) < 1 {
fmt.Fprintln(errOut, "build requires a markdown file path")
return 2
}
path := args[0]
cssPath := ""
for _, arg := range args[1:] {
if strings.HasPrefix(arg, "--css=") {
cssPath = strings.TrimPrefix(arg, "--css=")
}
}
file, err := os.Open(path)
if err != nil {
fmt.Fprintf(errOut, "failed to open %s: %v\n", path, err)
return 1
}
defer file.Close()
doc, err := invoice.ParseMarkdown(file)
if err != nil {
fmt.Fprintf(errOut, "parse error: %v\n", err)
return 1
}
if err := invoice.Validate(&doc); err != nil {
fmt.Fprintf(errOut, "validation error: %v\n", err)
return 1
}
html, err := renderWithCSS(doc, cssPath)
if err != nil {
fmt.Fprintf(errOut, "render error: %v\n", err)
return 1
}
outputPath := strings.TrimSuffix(path, filepath.Ext(path)) + ".html"
if err := os.WriteFile(outputPath, []byte(html), 0644); err != nil {
fmt.Fprintf(errOut, "failed to write html: %v\n", err)
return 1
}
fmt.Fprintf(out, "HTML generated at %s\n", outputPath)
return 0
}
func runServe(args []string, out io.Writer, errOut io.Writer) int {
if len(args) < 1 {
fmt.Fprintln(errOut, "serve requires a markdown file path")
return 2
}
path := args[0]
addr := "127.0.0.1:0"
cssPath := ""
for _, arg := range args[1:] {
if strings.HasPrefix(arg, "--addr=") {
addr = strings.TrimPrefix(arg, "--addr=")
}
if strings.HasPrefix(arg, "--css=") {
cssPath = strings.TrimPrefix(arg, "--css=")
}
}
err := server.Serve(server.Config{
Addr: addr,
MDPath: path,
CSSPath: cssPath,
}, out, errOut)
if err != nil {
fmt.Fprintf(errOut, "%v\n", err)
return 1
}
return 0
}
func renderWithCSS(doc invoice.Document, cssPath string) (string, error) {
if cssPath == "" {
return render.RenderHTML(doc)
}
css, err := os.ReadFile(cssPath)
if err != nil {
return "", err
}
return render.RenderHTMLWithCSS(doc, string(css))
}

56
internal/cli/run_test.go Normal file
View File

@ -0,0 +1,56 @@
package cli
import (
"bytes"
"os"
"path/filepath"
"testing"
"beelloo/internal/invoice"
)
func TestRunNewCreatesScaffold(t *testing.T) {
var out bytes.Buffer
var errOut bytes.Buffer
tmp := t.TempDir()
path := filepath.Join(tmp, "invoice.md")
code := Run([]string{"new", path}, &out, &errOut)
if code != 0 {
t.Fatalf("expected exit code 0, got %d: %s", code, errOut.String())
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read scaffold: %v", err)
}
if string(data) != invoice.ScaffoldMarkdown {
t.Fatalf("scaffold content mismatch")
}
}
func TestRunBuildErrors(t *testing.T) {
var out bytes.Buffer
var errOut bytes.Buffer
code := Run([]string{"build"}, &out, &errOut)
if code != 2 {
t.Fatalf("expected exit code 2, got %d", code)
}
code = Run([]string{"build", "missing.md"}, &out, &errOut)
if code != 1 {
t.Fatalf("expected exit code 1 for missing file, got %d", code)
}
}
func TestRunBuildValidationError(t *testing.T) {
var out bytes.Buffer
var errOut bytes.Buffer
tmp := t.TempDir()
path := filepath.Join(tmp, "bad.md")
if err := os.WriteFile(path, []byte("# Facture\n\n## Vendeur\nNom: \n"), 0644); err != nil {
t.Fatalf("write bad markdown: %v", err)
}
code := Run([]string{"build", path}, &out, &errOut)
if code != 1 {
t.Fatalf("expected exit code 1, got %d", code)
}
}

67
internal/invoice/money.go Normal file
View File

@ -0,0 +1,67 @@
package invoice
import (
"errors"
"fmt"
"strings"
)
func ParseMoney(input string) (Money, error) {
clean := strings.TrimSpace(input)
if clean == "" {
return 0, errors.New("empty money value")
}
sign := int64(1)
if strings.HasPrefix(clean, "-") {
sign = -1
clean = strings.TrimSpace(strings.TrimPrefix(clean, "-"))
}
clean = strings.ReplaceAll(clean, " ", "")
clean = strings.ReplaceAll(clean, ",", ".")
if strings.HasPrefix(clean, ".") || strings.HasSuffix(clean, ".") {
return 0, fmt.Errorf("invalid money value: %q", input)
}
parts := strings.Split(clean, ".")
if len(parts) > 2 {
return 0, fmt.Errorf("invalid money value: %q", input)
}
whole := parts[0]
frac := ""
if len(parts) == 2 {
frac = parts[1]
}
if whole == "" {
whole = "0"
}
for _, r := range whole {
if r < '0' || r > '9' {
return 0, fmt.Errorf("invalid money value: %q", input)
}
}
for _, r := range frac {
if r < '0' || r > '9' {
return 0, fmt.Errorf("invalid money value: %q", input)
}
}
if len(frac) > 2 {
return 0, fmt.Errorf("invalid money value: %q", input)
}
for len(frac) < 2 {
frac += "0"
}
var wholeValue int64
for _, r := range whole {
wholeValue = wholeValue*10 + int64(r-'0')
}
var fracValue int64
for _, r := range frac {
fracValue = fracValue*10 + int64(r-'0')
}
return Money(sign * (wholeValue*100 + fracValue)), nil
}
func formatCents(cents int64) string {
whole := cents / 100
frac := cents % 100
return fmt.Sprintf("%d.%02d", whole, frac)
}

View File

@ -0,0 +1,26 @@
package invoice
import "testing"
func TestParseMoney(t *testing.T) {
cases := []struct {
input string
want Money
}{
{"1175", 117500},
{"1175.5", 117550},
{"1175,50", 117550},
{"0.01", 1},
{"-15", -1500},
{"-0.5", -50},
}
for _, tc := range cases {
got, err := ParseMoney(tc.input)
if err != nil {
t.Fatalf("ParseMoney(%q) unexpected error: %v", tc.input, err)
}
if got != tc.want {
t.Fatalf("ParseMoney(%q)=%v want %v", tc.input, got, tc.want)
}
}
}

229
internal/invoice/parse.go Normal file
View File

@ -0,0 +1,229 @@
package invoice
import (
"bufio"
"errors"
"fmt"
"io"
"strings"
"time"
)
func ParseMarkdown(r io.Reader) (Document, error) {
var doc Document
var section string
var inAddress bool
var tableHeader []string
scanner := bufio.NewScanner(r)
lineNo := 0
for scanner.Scan() {
lineNo++
line := strings.TrimRight(scanner.Text(), "\r")
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") && strings.HasPrefix(trimmed, "## ") {
section = strings.TrimSpace(strings.TrimPrefix(trimmed, "## "))
inAddress = false
if strings.EqualFold(section, "Prestations") {
tableHeader = nil
}
continue
}
if trimmed == "" {
inAddress = false
continue
}
switch strings.ToLower(section) {
case "seller":
if ok := parsePartyLine(&doc.Seller, line, &inAddress); ok {
continue
}
case "buyer":
if ok := parsePartyLine(&doc.Buyer, line, &inAddress); ok {
continue
}
case "invoice":
key, value, ok := splitKeyValue(line)
if !ok {
return doc, fmt.Errorf("line %d: expected key/value in Invoice section", lineNo)
}
switch normalizeKey(key) {
case "number":
doc.Invoice.Number = value
case "subject":
doc.Invoice.Subject = value
case "location":
doc.Invoice.Location = value
case "date":
parsed, err := time.Parse("2006-01-02", value)
if err != nil {
return doc, fmt.Errorf("line %d: invalid date %q", lineNo, value)
}
doc.Invoice.Date = parsed
case "description":
doc.Invoice.Description = value
default:
return doc, fmt.Errorf("line %d: unknown invoice field %q", lineNo, key)
}
case "items":
if !strings.Contains(trimmed, "|") {
return doc, fmt.Errorf("line %d: expected table row in Items section", lineNo)
}
cells := splitTableRow(line)
if len(cells) == 0 {
continue
}
if tableHeader == nil {
tableHeader = cells
continue
}
if isSeparatorRow(cells) {
continue
}
item, err := parseItemRow(tableHeader, cells)
if err != nil {
return doc, fmt.Errorf("line %d: %w", lineNo, err)
}
doc.Items = append(doc.Items, item)
case "payment":
key, value, ok := splitKeyValue(line)
if !ok {
return doc, fmt.Errorf("line %d: expected key/value in Payment section", lineNo)
}
switch normalizeKey(key) {
case "holder":
doc.Payment.Holder = value
case "iban":
doc.Payment.IBAN = value
default:
return doc, fmt.Errorf("line %d: unknown payment field %q", lineNo, key)
}
default:
continue
}
}
if err := scanner.Err(); err != nil {
return doc, err
}
return doc, nil
}
func parsePartyLine(party *Party, line string, inAddress *bool) bool {
if *inAddress && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) {
party.Address = append(party.Address, strings.TrimSpace(line))
return true
}
key, value, ok := splitKeyValue(line)
if !ok {
return false
}
switch normalizeKey(key) {
case "name":
party.Name = value
*inAddress = false
return true
case "address":
*inAddress = true
if value != "" {
party.Address = append(party.Address, value)
}
return true
case "siret":
party.SIRET = value
*inAddress = false
return true
case "email":
party.Email = value
*inAddress = false
return true
case "phone":
party.Phone = value
*inAddress = false
return true
default:
return false
}
}
func splitKeyValue(line string) (string, string, bool) {
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
return "", "", false
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
return key, value, true
}
func normalizeKey(key string) string {
k := strings.ToLower(strings.TrimSpace(key))
replacer := strings.NewReplacer("é", "e", "è", "e", "ê", "e", "à", "a", "ç", "c")
return replacer.Replace(k)
}
func splitTableRow(line string) []string {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "|") {
return nil
}
trimmed = strings.TrimPrefix(trimmed, "|")
trimmed = strings.TrimSuffix(trimmed, "|")
parts := strings.Split(trimmed, "|")
var cells []string
for _, part := range parts {
cells = append(cells, strings.TrimSpace(part))
}
return cells
}
func isSeparatorRow(cells []string) bool {
for _, cell := range cells {
if cell == "" {
continue
}
for _, r := range cell {
if r != '-' {
return false
}
}
}
return true
}
func parseItemRow(header, row []string) (Item, error) {
if len(row) < len(header) {
return Item{}, errors.New("row has fewer columns than header")
}
idxDesignation := -1
idxPrice := -1
idxQty := -1
for i, col := range header {
n := normalizeKey(col)
switch {
case strings.Contains(n, "designation"):
idxDesignation = i
case strings.Contains(n, "unitprice"), strings.Contains(n, "unit price"), strings.Contains(n, "price"), strings.Contains(n, "prix"):
idxPrice = i
case strings.Contains(n, "quantity"), strings.Contains(n, "quantite"), strings.Contains(n, "qty"):
idxQty = i
}
}
if idxDesignation == -1 || idxPrice == -1 || idxQty == -1 {
return Item{}, errors.New("table header must include Designation, Unit price, Quantity")
}
item := Item{Designation: row[idxDesignation]}
if item.Designation == "" {
return Item{}, errors.New("empty designation")
}
price, err := ParseMoney(row[idxPrice])
if err != nil {
return Item{}, fmt.Errorf("invalid unit price: %w", err)
}
item.UnitPrice = price
qty, err := ParseQuantity(row[idxQty])
if err != nil {
return Item{}, fmt.Errorf("invalid quantity: %w", err)
}
item.Quantity = qty
return item, nil
}

View File

@ -0,0 +1,48 @@
package invoice
import (
"os"
"strings"
"testing"
)
func TestParseMarkdownSample(t *testing.T) {
file, err := os.Open("../../testdata/sample.md")
if err != nil {
t.Fatalf("open sample: %v", err)
}
defer file.Close()
doc, err := ParseMarkdown(file)
if err != nil {
t.Fatalf("ParseMarkdown: %v", err)
}
if doc.Seller.Name != "Alice Example" {
t.Fatalf("seller name mismatch: %q", doc.Seller.Name)
}
if doc.Seller.Email != "alice@example.com" {
t.Fatalf("seller email mismatch: %q", doc.Seller.Email)
}
if doc.Seller.Phone != "01 02 03 04 05" {
t.Fatalf("seller phone mismatch: %q", doc.Seller.Phone)
}
if doc.Buyer.Name != "Example Corp" {
t.Fatalf("buyer name mismatch: %q", doc.Buyer.Name)
}
if doc.Invoice.Number != "20250407" {
t.Fatalf("invoice number mismatch: %q", doc.Invoice.Number)
}
if len(doc.Items) != 1 {
t.Fatalf("expected 1 item, got %d", len(doc.Items))
}
if !strings.HasPrefix(doc.Items[0].Designation, "Forfait IT") {
t.Fatalf("designation mismatch: %q", doc.Items[0].Designation)
}
}
func TestValidateMissingFields(t *testing.T) {
doc := Document{}
if err := Validate(&doc); err == nil {
t.Fatalf("expected validation error")
}
}

View File

@ -0,0 +1,70 @@
package invoice
import (
"errors"
"fmt"
"strings"
)
// Quantity stores hundredths (e.g. 0.5 => 50).
func ParseQuantity(input string) (Quantity, error) {
clean := strings.TrimSpace(input)
if clean == "" {
return 0, errors.New("empty quantity")
}
clean = strings.ReplaceAll(clean, " ", "")
clean = strings.ReplaceAll(clean, ",", ".")
if strings.HasPrefix(clean, ".") || strings.HasSuffix(clean, ".") {
return 0, fmt.Errorf("invalid quantity: %q", input)
}
parts := strings.Split(clean, ".")
if len(parts) > 2 {
return 0, fmt.Errorf("invalid quantity: %q", input)
}
whole := parts[0]
frac := ""
if len(parts) == 2 {
frac = parts[1]
}
if whole == "" {
whole = "0"
}
for _, r := range whole {
if r < '0' || r > '9' {
return 0, fmt.Errorf("invalid quantity: %q", input)
}
}
for _, r := range frac {
if r < '0' || r > '9' {
return 0, fmt.Errorf("invalid quantity: %q", input)
}
}
if len(frac) > 2 {
return 0, fmt.Errorf("invalid quantity: %q", input)
}
for len(frac) < 2 {
frac += "0"
}
var wholeValue int64
for _, r := range whole {
wholeValue = wholeValue*10 + int64(r-'0')
}
var fracValue int64
for _, r := range frac {
fracValue = fracValue*10 + int64(r-'0')
}
return Quantity(wholeValue*100 + fracValue), nil
}
func FormatQuantity(q Quantity) string {
value := int64(q)
whole := value / 100
frac := value % 100
if frac == 0 {
return fmt.Sprintf("%d", whole)
}
if frac%10 == 0 {
return fmt.Sprintf("%d.%d", whole, frac/10)
}
return fmt.Sprintf("%d.%02d", whole, frac)
}

View File

@ -0,0 +1,52 @@
package invoice
import "testing"
func TestParseQuantity(t *testing.T) {
cases := []struct {
input string
want Quantity
}{
{"1", 100},
{"0.5", 50},
{"2,25", 225},
{"10.00", 1000},
}
for _, tc := range cases {
got, err := ParseQuantity(tc.input)
if err != nil {
t.Fatalf("ParseQuantity(%q) unexpected error: %v", tc.input, err)
}
if got != tc.want {
t.Fatalf("ParseQuantity(%q)=%v want %v", tc.input, got, tc.want)
}
}
}
func TestFormatQuantity(t *testing.T) {
cases := []struct {
input Quantity
want string
}{
{100, "1"},
{50, "0.5"},
{225, "2.25"},
{1010, "10.1"},
}
for _, tc := range cases {
got := FormatQuantity(tc.input)
if got != tc.want {
t.Fatalf("FormatQuantity(%v)=%q want %q", tc.input, got, tc.want)
}
}
}
func TestLineAmountNegative(t *testing.T) {
item := Item{
UnitPrice: -29500,
Quantity: 100,
}
if got := LineAmount(item); got != -29500 {
t.Fatalf("LineAmount negative mismatch: %v", got)
}
}

View File

@ -0,0 +1,34 @@
package invoice
const ScaffoldMarkdown = `# Invoice
## Seller
Name:
Address:
Email:
Phone:
SIRET:
## Buyer
Name:
Address:
SIRET:
## Invoice
Number:
Subject: Facture pour prestations de service
Location:
Date: 2025-04-07
Description:
## Items
| Designation | Unit price | Quantity |
| --- | --- | --- |
| | | |
## Payment
Holder:
IBAN:
`

57
internal/invoice/types.go Normal file
View File

@ -0,0 +1,57 @@
package invoice
import "time"
type Document struct {
Seller Party
Buyer Party
Invoice InvoiceInfo
Items []Item
Payment PaymentInfo
Totals Totals
}
type Party struct {
Name string
Address []string
SIRET string
Email string
Phone string
}
type InvoiceInfo struct {
Number string
Subject string
Location string
Date time.Time
Description string
}
type Item struct {
Designation string
UnitPrice Money
Quantity Quantity
}
type PaymentInfo struct {
Holder string
IBAN string
}
type Totals struct {
TotalExclTax Money
}
type Money int64
type Quantity int64
func (m Money) String() string {
sign := ""
value := m
if value < 0 {
sign = "-"
value = -value
}
return sign + formatCents(int64(value))
}

View File

@ -0,0 +1,98 @@
package invoice
import (
"errors"
"fmt"
)
func Validate(doc *Document) error {
var problems []string
if doc.Seller.Name == "" {
problems = append(problems, "missing vendeur nom")
}
if len(doc.Seller.Address) == 0 {
problems = append(problems, "missing vendeur adresse")
}
if doc.Seller.Email == "" {
problems = append(problems, "missing vendeur email")
}
if doc.Seller.Phone == "" {
problems = append(problems, "missing vendeur telephone")
}
if doc.Buyer.Name == "" {
problems = append(problems, "missing acheteur nom")
}
if len(doc.Buyer.Address) == 0 {
problems = append(problems, "missing acheteur adresse")
}
if doc.Invoice.Number == "" {
problems = append(problems, "missing facture numero")
}
if doc.Invoice.Subject == "" {
problems = append(problems, "missing facture objet")
}
if doc.Invoice.Location == "" {
problems = append(problems, "missing facture lieu")
}
if doc.Invoice.Date.IsZero() {
problems = append(problems, "missing facture date")
}
if doc.Invoice.Description == "" {
problems = append(problems, "missing facture description")
}
if len(doc.Items) == 0 {
problems = append(problems, "missing prestations")
}
if doc.Payment.Holder == "" {
problems = append(problems, "missing paiement titulaire")
}
if doc.Payment.IBAN == "" {
problems = append(problems, "missing paiement IBAN")
}
if len(problems) > 0 {
return errors.New(stringsJoin(problems, "; "))
}
return nil
}
func ComputeTotals(doc *Document) {
var total Money
for _, item := range doc.Items {
line := LineAmount(item)
total += line
}
doc.Totals.TotalExclTax = total
}
func stringsJoin(values []string, sep string) string {
if len(values) == 0 {
return ""
}
out := values[0]
for i := 1; i < len(values); i++ {
out += sep + values[i]
}
return out
}
func FormatMoney(m Money) string {
return m.String()
}
func LineAmount(item Item) Money {
// Quantity is stored in hundredths. Round to nearest cent.
cents := int64(item.UnitPrice)
qty := int64(item.Quantity)
product := cents * qty
if product >= 0 {
return Money((product + 50) / 100)
}
return Money((product - 50) / 100)
}
func RequireNoError(err error, msg string) error {
if err != nil {
return fmt.Errorf("%s: %w", msg, err)
}
return nil
}

103
internal/render/render.go Normal file
View File

@ -0,0 +1,103 @@
package render
import (
"bytes"
"embed"
"html/template"
"time"
"github.com/russross/blackfriday/v2"
"beelloo/internal/invoice"
)
//go:embed template.html style.css
var assets embed.FS
type renderItem struct {
Designation template.HTML
UnitPrice string
Quantity string
Amount string
}
type renderData struct {
Seller invoice.Party
Buyer invoice.Party
Invoice invoice.InvoiceInfo
InvoiceDate string
InvoiceDescription template.HTML
Items []renderItem
TotalExclTax string
Payment invoice.PaymentInfo
CSS template.CSS
CSSHref string
}
func RenderHTML(doc invoice.Document) (string, error) {
cssBytes, err := assets.ReadFile("style.css")
if err != nil {
return "", err
}
return RenderHTMLWithCSS(doc, string(cssBytes))
}
func RenderHTMLWithCSS(doc invoice.Document, css string) (string, error) {
return renderHTML(doc, css, "")
}
func RenderHTMLWithCSSLink(doc invoice.Document, href string) (string, error) {
return renderHTML(doc, "", href)
}
func renderHTML(doc invoice.Document, css string, href string) (string, error) {
invoice.ComputeTotals(&doc)
tmplBytes, err := assets.ReadFile("template.html")
if err != nil {
return "", err
}
tmpl, err := template.New("invoice").Parse(string(tmplBytes))
if err != nil {
return "", err
}
data := renderData{
Seller: doc.Seller,
Buyer: doc.Buyer,
Invoice: doc.Invoice,
InvoiceDate: formatDate(doc.Invoice.Date),
InvoiceDescription: markdownHTML(doc.Invoice.Description),
TotalExclTax: invoice.FormatMoney(doc.Totals.TotalExclTax),
Payment: doc.Payment,
CSS: template.CSS(css),
CSSHref: href,
}
for _, item := range doc.Items {
data.Items = append(data.Items, renderItem{
Designation: markdownHTML(item.Designation),
UnitPrice: invoice.FormatMoney(item.UnitPrice),
Quantity: invoice.FormatQuantity(item.Quantity),
Amount: invoice.FormatMoney(invoice.LineAmount(item)),
})
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
func DefaultCSS() ([]byte, error) {
return assets.ReadFile("style.css")
}
func markdownHTML(input string) template.HTML {
output := blackfriday.Run([]byte(input))
return template.HTML(output)
}
func formatDate(date time.Time) string {
if date.IsZero() {
return ""
}
return date.Format("02/01/2006")
}

View File

@ -0,0 +1,38 @@
package render
import (
"os"
"testing"
"beelloo/internal/invoice"
)
func TestRenderHTMLGolden(t *testing.T) {
file, err := os.Open("../../testdata/sample.md")
if err != nil {
t.Fatalf("open sample: %v", err)
}
defer file.Close()
doc, err := invoice.ParseMarkdown(file)
if err != nil {
t.Fatalf("ParseMarkdown: %v", err)
}
if err := invoice.Validate(&doc); err != nil {
t.Fatalf("Validate: %v", err)
}
html, err := RenderHTML(doc)
if err != nil {
t.Fatalf("RenderHTML: %v", err)
}
goldenPath := "../../testdata/sample.html"
golden, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("read golden: %v", err)
}
if string(golden) != html {
t.Fatalf("render output did not match golden file")
}
}

175
internal/render/style.css Normal file
View File

@ -0,0 +1,175 @@
@page {
size: A4;
margin: 2cm 1cm;
}
* {
box-sizing: border-box;
}
@media print {
body {
margin: 0;
}
.page {
padding: 0;
}
}
@media screen {
body {
margin: 0 auto;
width: 21cm;
}
.page {
padding: 20mm;
}
}
@media all {
body {
font-family: "TeX Gyre Pagella", "Palatino Linotype", "Book Antiqua", Palatino, serif;
font-size: 12pt;
color: #111;
background: #fff;
}
.page {
min-height: calc(297mm - 4cm);
display: flex;
flex-direction: column;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12mm;
}
.seller-name,
.buyer-name {
font-weight: bold;
font-size: 12.5pt;
margin-bottom: 3mm;
}
.seller-line,
.buyer-line {
margin-bottom: 2mm;
}
.buyer {
text-align: left;
max-width: 42%;
}
.meta {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12mm;
}
.invoice-number {
font-weight: bold;
}
.object {
margin-bottom: 20mm;
}
.object-title {
font-weight: bold;
margin-bottom: 3mm;
}
.object-desc p {
margin: 0;
}
.table-section {
margin-bottom: 15mm;
}
.items {
width: 100%;
border-collapse: collapse;
font-size: 11pt;
}
.items th {
padding: 3mm 2mm 2mm 2mm;
border-bottom: 1px solid #666;
}
.items td {
padding: 3mm 2mm;
vertical-align: top;
}
.col-price,
.col-qty,
.col-amount {
text-align: right;
width: 18%;
}
.col-designation {
width: 46%;
}
.col-designation p {
margin: 0;
}
.total-row td {
border-top: 1px solid #666;
padding-top: 4mm;
}
.total-label {
text-align: right;
font-weight: bold;
}
.total-value {
text-align: right;
font-weight: bold;
}
.vat-note {
padding-top: 2mm;
font-size: 10.5pt;
}
.footer {
margin-top: auto;
padding-top: 12mm;
text-align: center;
}
.payment-title {
text-align: left;
margin-bottom: 6mm;
}
.payment-detail {
margin-bottom: 2mm;
}
.seller-contact {
font-size: 10.5pt;
margin-top: 12mm;
}
.contact-item + .contact-item::before {
content: " — ";
}
.payment-detail {
margin-bottom: 2mm;
}
}

View File

@ -0,0 +1,83 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<title>Facture {{.Invoice.Number}}</title>
{{if .CSSHref}}
<link rel="stylesheet" href="{{.CSSHref}}">
{{else}}
<style>
{{.CSS}}
</style>
{{end}}
</head>
<body>
<div class="page">
<header class="header">
<div class="seller">
<div class="seller-name">{{.Seller.Name}}</div>
{{range .Seller.Address}}<div class="seller-line">{{.}}</div>{{end}}
</div>
<div class="buyer">
<div class="buyer-name">{{.Buyer.Name}}</div>
{{range .Buyer.Address}}<div class="buyer-line">{{.}}</div>{{end}}
{{if .Buyer.SIRET}}<div class="buyer-line">SIRET : {{.Buyer.SIRET}}</div>{{end}}
</div>
</header>
<section class="meta">
<div class="invoice-number">Facture n° {{.Invoice.Number}}</div>
<div class="invoice-date">{{.Invoice.Location}}, le {{.InvoiceDate}}</div>
</section>
<section class="object">
<div class="object-title">Objet : {{.Invoice.Subject}}</div>
<div class="object-desc">{{.InvoiceDescription}}</div>
</section>
<section class="table-section">
<table class="items">
<thead>
<tr>
<th class="col-designation">Désignation</th>
<th class="col-price">Prix unitaire</th>
<th class="col-qty">Quantité</th>
<th class="col-amount">Montant (EUR)</th>
</tr>
</thead>
<tbody>
{{range .Items}}
<tr>
<td class="col-designation">{{.Designation}}</td>
<td class="col-price">{{.UnitPrice}}</td>
<td class="col-qty">{{.Quantity}}</td>
<td class="col-amount">{{.Amount}}</td>
</tr>
{{end}}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="3" class="total-label">Total HT</td>
<td class="total-value">{{.TotalExclTax}}</td>
</tr>
<tr>
<td colspan="4" class="vat-note">TVA non applicable, art 293 B du CGI</td>
</tr>
</tfoot>
</table>
</section>
<footer class="footer">
<div class="payment-title">Paiement souhaité par virement bancaire :</div>
<div class="payment-detail"><strong>Nom associé au compte bancaire</strong> : {{.Payment.Holder}}</div>
<div class="payment-detail"><strong>IBAN N°</strong> : {{.Payment.IBAN}}</div>
<div class="seller-contact">
<span class="contact-item">{{.Seller.Name}}</span>
{{if .Seller.Email}}<span class="contact-item">E-mail : {{.Seller.Email}}</span>{{end}}
{{if .Seller.Phone}}<span class="contact-item">Téléphone : {{.Seller.Phone}}</span>{{end}}
{{if .Seller.SIRET}}<span class="contact-item">SIRET : {{.Seller.SIRET}}</span>{{end}}
</div>
</footer>
</div>
</body>
</html>

119
internal/server/server.go Normal file
View File

@ -0,0 +1,119 @@
package server
import (
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"time"
"beelloo/internal/invoice"
"beelloo/internal/render"
)
type Config struct {
Addr string
MDPath string
CSSPath string
}
type Result struct {
Listener net.Listener
}
func Serve(cfg Config, out, errOut io.Writer) error {
if cfg.Addr == "" {
cfg.Addr = "127.0.0.1:0"
}
cssPath, err := resolveCSSPath(cfg.MDPath, cfg.CSSPath)
if err != nil {
return err
}
cfg.CSSPath = cssPath
listener, err := net.Listen("tcp", cfg.Addr)
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", cfg.Addr, err)
}
mux := http.NewServeMux()
mux.HandleFunc("/style.css", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
if cfg.CSSPath != "" {
http.ServeFile(w, r, cfg.CSSPath)
return
}
css, err := render.DefaultCSS()
if err != nil {
http.Error(w, "failed to load default css", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/css; charset=utf-8")
_, _ = w.Write(css)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
file, err := os.Open(cfg.MDPath)
if err != nil {
http.Error(w, "failed to open markdown", http.StatusInternalServerError)
return
}
defer file.Close()
doc, err := invoice.ParseMarkdown(file)
if err != nil {
http.Error(w, "parse error: "+err.Error(), http.StatusBadRequest)
return
}
if err := invoice.Validate(&doc); err != nil {
http.Error(w, "validation error: "+err.Error(), http.StatusBadRequest)
return
}
html, err := render.RenderHTMLWithCSSLink(doc, fmt.Sprintf("/style.css?v=%d", time.Now().UnixNano()))
if err != nil {
http.Error(w, "render error: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
})
server := &http.Server{Handler: mux}
if cfg.CSSPath != "" {
fmt.Fprintf(out, "Using CSS: %s\n", cfg.CSSPath)
} else {
fmt.Fprintln(out, "Using embedded CSS")
}
fmt.Fprintf(out, "Serving on http://%s/\n", listener.Addr().String())
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("server error: %w", err)
}
return nil
}
func resolveCSSPath(mdPath, override string) (string, error) {
if override != "" {
if _, err := os.Stat(override); err != nil {
return "", fmt.Errorf("failed to stat css file %s: %w", override, err)
}
return override, nil
}
if mdPath == "" {
return "", nil
}
mdDir := filepath.Dir(mdPath)
candidates := []string{
filepath.Join(mdDir, "style.css"),
"style.css",
}
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
return path, nil
}
}
return "", nil
}