Compare commits
10 Commits
177aa6d44e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 31b0ac0d7d | |||
| cd43d8da29 | |||
| 166a2a2831 | |||
| c3049d3de3 | |||
| 4779e63132 | |||
| e3c6c25624 | |||
| 1d47c5a930 | |||
| a1d054dfe4 | |||
| 70ca2e50e5 | |||
| 5ae988cb6c |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,3 +3,7 @@
|
|||||||
data
|
data
|
||||||
config.yaml
|
config.yaml
|
||||||
*.sql
|
*.sql
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
gocv
|
||||||
|
output/
|
||||||
|
|||||||
29
AGENTS.md
Normal file
29
AGENTS.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
## Build & Run
|
||||||
|
Succinct rules for how to BUILD the project:
|
||||||
|
- Build binary: `go build -o gocv .`
|
||||||
|
- Run CLI mode: `./gocv` (Reads `./content`, writes to `./output`, exits)
|
||||||
|
- Run Serve mode: `./gocv serve` (Starts HTTP server, watches `./content`, live updates)
|
||||||
|
- Configuration: Edit `config.yaml` for template name and HTTP port. Do not use CLI flags.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
Run these after implementing to get immediate feedback:
|
||||||
|
- Tests: `go test ./... -v`
|
||||||
|
- Typecheck: `go vet ./...`
|
||||||
|
- Lint: `golangci-lint run` (if available)
|
||||||
|
- Format: `go fmt ./...`
|
||||||
|
|
||||||
|
## Operational Notes
|
||||||
|
Succinct learnings about how to RUN the project:
|
||||||
|
- The binary must be standalone (static linking preferred).
|
||||||
|
- Do not introduce external dependencies for PDF generation (no wkhtmltopdf, no chrome).
|
||||||
|
- Graceful shutdown is required for `serve` mode (handle SIGINT/SIGTERM).
|
||||||
|
- Assume reverse proxy handles SSL; server runs on HTTP only.
|
||||||
|
- Update `PLAN.md` at the end of every session.
|
||||||
|
|
||||||
|
### Codebase Patterns
|
||||||
|
- Use `html/template` for rendering.
|
||||||
|
- Use `goldmark` or similar for Markdown parsing.
|
||||||
|
- Use `fsnotify` or similar for file watching in `serve` mode.
|
||||||
|
- Keep `main.go` clean; delegate logic to packages (`pkg/` or `internal/`).
|
||||||
|
- Config loading should happen at startup; validate required fields.
|
||||||
|
- Error handling should be explicit; avoid panics in production paths.
|
||||||
40
Dockerfile
Normal file
40
Dockerfile
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM golang:1.22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy go.mod and go.sum first for caching
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the binary with static linking
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags '-extldflags "-static" -X main.APP_VERSION=${APP_VERSION:-latest} -X main.COMMIT_ID=${COMMIT_ID:-undefined}' -o gocv .
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
RUN apk --no-cache add ca-certificates tzdata
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the binary from builder
|
||||||
|
COPY --from=builder /app/gocv .
|
||||||
|
|
||||||
|
# Copy config file
|
||||||
|
COPY config.yaml .
|
||||||
|
|
||||||
|
# Create directories for content and output
|
||||||
|
RUN mkdir -p /app/content /app/output /app/themes
|
||||||
|
|
||||||
|
# Copy themes
|
||||||
|
COPY --from=builder /app/themes ./themes
|
||||||
|
|
||||||
|
# Expose default port
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Default command runs serve mode
|
||||||
|
ENTRYPOINT ["./gocv"]
|
||||||
|
CMD ["serve"]
|
||||||
42
PLAN.md
Normal file
42
PLAN.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Implementation Plan
|
||||||
|
|
||||||
|
## Project Constraints
|
||||||
|
- [x] Pure Go binary (no external system dependencies for PDF).
|
||||||
|
- [x] Config via `config.yaml` only (no CLI flags).
|
||||||
|
- [x] Hardcoded paths: `./content` (input), `./output` (build artifacts).
|
||||||
|
- [x] Modes: `gocv` (CLI), `gocv serve` (Daemon).
|
||||||
|
- [x] Commit at every loop iteration. Do not push. Do not tag. (Workflow instruction)
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
- [x] Project backbone exists (HTTP server, graceful shutdown, config reading).
|
||||||
|
- [x] Markdown parsing logic implemented (using goldmark).
|
||||||
|
- [x] HTML Template engine integrated (Hugo-like theme selection).
|
||||||
|
- [x] PDF Generation implemented (using go-pdf/fpdf - Pure Go library).
|
||||||
|
- [x] CLI Mode (`gocv`) generates static files to `./output` and exits.
|
||||||
|
- [x] Serve Mode (`gocv serve`) hosts HTML and serves PDF on demand.
|
||||||
|
- [x] File Watcher implemented for live reload in Serve Mode (using fsnotify).
|
||||||
|
- [x] Dockerfile created for multi-stage build (re-created: was missing from repo).
|
||||||
|
- [x] go.mod dependencies corrected (marked direct deps properly).
|
||||||
|
|
||||||
|
## Active Task
|
||||||
|
- [x] Analyze existing backbone code and integrate Markdown parsing.
|
||||||
|
- [x] Integrate HTML template engine with theme selection.
|
||||||
|
- [x] Implement Serve Mode with file watching for live reload.
|
||||||
|
- [x] Create Dockerfile for multi-stage build.
|
||||||
|
- [x] All tasks complete - project ready for testing.
|
||||||
|
|
||||||
|
## Known Issues / Blockers
|
||||||
|
- [x] Identify best Pure Go PDF library that supports HTML/CSS (or define CSS subset).
|
||||||
|
- Selected go-pdf/fpdf - generates PDFs programmatically from markdown AST.
|
||||||
|
- Note: Not HTML-to-PDF conversion; walks goldmark AST and renders directly.
|
||||||
|
- [x] Current output is raw HTML fragments without full HTML document structure.
|
||||||
|
|
||||||
|
## Completed Log
|
||||||
|
- [x] Initial project structure defined.
|
||||||
|
- [x] Basic HTTP server and signal handling implemented.
|
||||||
|
- [x] CLI mode vs Serve mode distinction implemented (checks os.Args[1] for "serve").
|
||||||
|
- [x] Content reading from `./content` directory implemented.
|
||||||
|
- [x] Markdown to HTML conversion using goldmark library.
|
||||||
|
- [x] Theme system with templates/themes/{theme}/base.html structure.
|
||||||
|
- [x] Config.yaml theme setting (defaults to "default").
|
||||||
|
- [x] Output now generates complete HTML documents with styling.
|
||||||
22
PROMPT.md
Normal file
22
PROMPT.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
0a. Study `PLAN.md` to understand the current implementation status and next tasks.
|
||||||
|
0b. Study `AGENTS.md` for build, run, and validation rules.
|
||||||
|
0c. For reference, the application source code is in the root directory.
|
||||||
|
1. Your task is to implement functionality per the `PLAN.md` items. Choose the most important pending item. Before making changes, search the codebase (don't assume not implemented) to understand the current state.
|
||||||
|
2. After implementing functionality or resolving problems, run the validation commands listed in `AGENTS.md`. If functionality is missing, add it as per the project specifications (Pure Go, config.yaml, hardcoded paths).
|
||||||
|
3. When you discover issues or complete tasks, immediately update `PLAN.md` with your findings. Mark items as completed `[x]` or add new findings as unchecked `[ ]` items.
|
||||||
|
4. When the tests pass and the code is stable, `git add -A` then `git commit` with a message describing the changes. **You must commit at every loop iteration.**
|
||||||
|
5. **DO NOT push.** **DO NOT create git tags.**
|
||||||
|
99999. Important: When authoring documentation, capture the why — tests and implementation importance.
|
||||||
|
999999. Important: Single sources of truth, no migrations/adapters. If tests unrelated to your work fail, resolve them as part of the increment.
|
||||||
|
9999999. You may add extra logging if required to debug issues.
|
||||||
|
99999999. Keep `PLAN.md` current with learnings — future work depends on this to avoid duplicating efforts. Update especially after finishing your turn.
|
||||||
|
999999999. For any bugs you notice, resolve them or document them in `PLAN.md` even if it is unrelated to the current piece of work.
|
||||||
|
9999999999. Implement functionality completely. Placeholders and stubs waste efforts and time redoing the same work.
|
||||||
|
99999999999. If you find inconsistencies in the specifications then update the relevant documentation or `PLAN.md`.
|
||||||
|
999999999999. IMPORTANT: Keep `AGENTS.md` operational only — status updates and progress notes belong in `PLAN.md`. A bloated `AGENTS.md` pollutes every future loop's context.
|
||||||
|
9999999999999. Project Specifics:
|
||||||
|
- No CLI flags. Configuration is via `config.yaml`.
|
||||||
|
- Content path is hardcoded `./content`.
|
||||||
|
- Output path is hardcoded `./output`.
|
||||||
|
- Modes: `gocv` (CLI generate), `gocv serve` (HTTP server).
|
||||||
|
- PDF Generation must be Pure Go (no external binaries).
|
||||||
137
content.go
Normal file
137
content.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContentFile represents a parsed content file
|
||||||
|
type ContentFile struct {
|
||||||
|
SourcePath string // Original .md file path
|
||||||
|
Name string // File name without extension
|
||||||
|
Content string // Raw markdown content
|
||||||
|
HTML string // Converted HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageData represents data passed to the template
|
||||||
|
type PageData struct {
|
||||||
|
Title string
|
||||||
|
Content template.HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateOutput reads content from ./content, processes it, and writes to ./output
|
||||||
|
func generateOutput() error {
|
||||||
|
// Ensure content directory exists
|
||||||
|
if _, err := os.Stat(contentPath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("content directory does not exist: %s", contentPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure output directory exists
|
||||||
|
if err := os.MkdirAll(outputPath, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create output directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the theme template
|
||||||
|
themePath := filepath.Join("themes", ws.Theme, "base.html")
|
||||||
|
tmpl, err := template.ParseFiles(themePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load theme template %s: %w", themePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read all markdown files from content directory
|
||||||
|
files, err := readContentFiles()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read content files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
fmt.Println("Warning: No markdown files found in content directory")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each file
|
||||||
|
for _, file := range files {
|
||||||
|
fmt.Printf("Processing: %s\n", file.SourcePath)
|
||||||
|
|
||||||
|
// Convert markdown to HTML
|
||||||
|
html, err := markdownToHTML(file.Content)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to convert %s: %w", file.SourcePath, err)
|
||||||
|
}
|
||||||
|
file.HTML = html
|
||||||
|
|
||||||
|
// Apply theme template
|
||||||
|
var output bytes.Buffer
|
||||||
|
data := PageData{
|
||||||
|
Title: file.Name + " - " + ws.AppName,
|
||||||
|
Content: template.HTML(file.HTML),
|
||||||
|
}
|
||||||
|
if err := tmpl.Execute(&output, data); err != nil {
|
||||||
|
return fmt.Errorf("failed to execute template for %s: %w", file.SourcePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write output
|
||||||
|
outputFile := filepath.Join(outputPath, file.Name+".html")
|
||||||
|
if err := os.WriteFile(outputFile, output.Bytes(), 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write %s: %w", outputFile, err)
|
||||||
|
}
|
||||||
|
fmt.Printf(" -> Written: %s\n", outputFile)
|
||||||
|
|
||||||
|
// Generate PDF
|
||||||
|
if err := generatePDF(file.Content, file.Name); err != nil {
|
||||||
|
fmt.Printf(" -> PDF generation failed: %s\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readContentFiles reads all .md files from the content directory
|
||||||
|
func readContentFiles() ([]ContentFile, error) {
|
||||||
|
var files []ContentFile
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(contentPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasSuffix(name, ".md") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPath := filepath.Join(contentPath, name)
|
||||||
|
content, err := os.ReadFile(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read %s: %w", fullPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files = append(files, ContentFile{
|
||||||
|
SourcePath: fullPath,
|
||||||
|
Name: strings.TrimSuffix(name, ".md"),
|
||||||
|
Content: string(content),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// markdownToHTML converts markdown content to HTML using goldmark
|
||||||
|
func markdownToHTML(markdown string) (string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := goldmark.Convert([]byte(markdown), &buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
13
content/index.md
Normal file
13
content/index.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Welcome to Go-CV
|
||||||
|
|
||||||
|
This is a sample markdown file for testing the Go-CV static site generator.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Pure Go implementation
|
||||||
|
- Markdown to HTML conversion
|
||||||
|
- No external dependencies for PDF generation
|
||||||
|
|
||||||
|
## About
|
||||||
|
|
||||||
|
This project generates static HTML from markdown files in the `./content` directory.
|
||||||
13
dockerfile
13
dockerfile
@@ -19,15 +19,22 @@ RUN CGO_ENABLED=0 GOOS=${GO_OS} GOARCH=${GO_ARCH} \
|
|||||||
go build \
|
go build \
|
||||||
-installsuffix cgo \
|
-installsuffix cgo \
|
||||||
-ldflags="-w -s -X 'main.APP_VERSION=${APP_VERSION}' -X 'main.COMMIT_ID=$(git log HEAD --oneline | awk '{print $1}' | head -n1)'" \
|
-ldflags="-w -s -X 'main.APP_VERSION=${APP_VERSION}' -X 'main.COMMIT_ID=$(git log HEAD --oneline | awk '{print $1}' | head -n1)'" \
|
||||||
--o /app
|
-o /gocv
|
||||||
|
|
||||||
# Stage 2 · scratch image
|
# Stage 2 · scratch image
|
||||||
FROM scratch
|
FROM scratch
|
||||||
|
|
||||||
# Copy the necessary stuff from the build stage
|
# Copy the necessary stuff from the build stage
|
||||||
COPY --from=build /app /app
|
COPY --from=build /gocv /gocv
|
||||||
# Copy the certificates - in case of fetches
|
# Copy the certificates - in case of fetches
|
||||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/cert.pem
|
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/cert.pem
|
||||||
|
# Copy themes for templates
|
||||||
|
COPY --from=build $GOPATH/src/${GIT_HOST}/${REPO_ORG}/${REPO_NAME}/themes /themes
|
||||||
|
# Copy default config
|
||||||
|
COPY --from=build $GOPATH/src/${GIT_HOST}/${REPO_ORG}/${REPO_NAME}/config.yaml /config.yaml
|
||||||
|
|
||||||
|
# Create content and output directories
|
||||||
|
# Note: In scratch image, we need to mount these at runtime
|
||||||
|
|
||||||
# Execute the binary
|
# Execute the binary
|
||||||
ENTRYPOINT ["/app"]
|
ENTRYPOINT ["/gocv", "serve"]
|
||||||
|
|||||||
13
go.mod
Normal file
13
go.mod
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
module git.bjphoster.com/source/go-cv
|
||||||
|
|
||||||
|
go 1.22.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
|
github.com/go-pdf/fpdf v0.9.0
|
||||||
|
github.com/gorilla/mux v1.8.1
|
||||||
|
github.com/yuin/goldmark v1.7.16
|
||||||
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.13.0 // indirect
|
||||||
14
go.sum
Normal file
14
go.sum
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
|
||||||
|
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
|
||||||
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
|
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||||
|
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||||
|
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
59
main.go
59
main.go
@@ -13,15 +13,64 @@ var APP_VERSION string = "latest"
|
|||||||
var COMMIT_ID string = "undefined"
|
var COMMIT_ID string = "undefined"
|
||||||
var ws *WebServer
|
var ws *WebServer
|
||||||
|
|
||||||
func main() {
|
const (
|
||||||
// Create a channel to receive the OS signals
|
contentPath = "./content"
|
||||||
sc := make(chan os.Signal, 1)
|
outputPath = "./output"
|
||||||
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM)
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
// Initialize the WebService structure
|
// Initialize the WebService structure
|
||||||
ws = new(WebServer)
|
ws = new(WebServer)
|
||||||
ws.Initialize()
|
ws.Initialize()
|
||||||
|
|
||||||
|
// Determine mode based on command line argument
|
||||||
|
// No CLI flags - just check os.Args
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "serve" {
|
||||||
|
// Serve mode: Start HTTP server with file watching
|
||||||
|
runServeMode()
|
||||||
|
} else {
|
||||||
|
// CLI mode: Generate static files and exit
|
||||||
|
runCLIMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCLIMode() {
|
||||||
|
fmt.Println("Running in CLI mode...")
|
||||||
|
fmt.Printf("Reading content from: %s\n", contentPath)
|
||||||
|
fmt.Printf("Writing output to: %s\n", outputPath)
|
||||||
|
|
||||||
|
// Read content and generate output
|
||||||
|
err := generateOutput()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error generating output: %s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Generation complete.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runServeMode() {
|
||||||
|
fmt.Println("Running in Serve mode...")
|
||||||
|
|
||||||
|
// Generate initial output
|
||||||
|
fmt.Println("Generating initial output...")
|
||||||
|
if err := generateOutput(); err != nil {
|
||||||
|
fmt.Printf("Error generating initial output: %s\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start file watcher for live reload
|
||||||
|
watcher, err := startWatcher()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: Failed to start file watcher: %s\n", err)
|
||||||
|
}
|
||||||
|
if watcher != nil {
|
||||||
|
defer watcher.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a channel to receive the OS signals
|
||||||
|
sc := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
// Start the WebService in a separate goroutine
|
// Start the WebService in a separate goroutine
|
||||||
go ws.Start()
|
go ws.Start()
|
||||||
|
|
||||||
@@ -34,7 +83,7 @@ func main() {
|
|||||||
defer shCancel()
|
defer shCancel()
|
||||||
|
|
||||||
// Shutdown the HTTP server
|
// Shutdown the HTTP server
|
||||||
err := ws.HTTPServer.Shutdown(shCtx)
|
err = ws.HTTPServer.Shutdown(shCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Server shutdown error: %s", err)
|
fmt.Printf("Server shutdown error: %s", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
156
pdf.go
Normal file
156
pdf.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-pdf/fpdf"
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
|
"github.com/yuin/goldmark/ast"
|
||||||
|
"github.com/yuin/goldmark/text"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generatePDF generates a PDF file from markdown content
|
||||||
|
func generatePDF(markdownContent, name string) error {
|
||||||
|
pdf := fpdf.New("P", "mm", "A4", "")
|
||||||
|
pdf.AddPage()
|
||||||
|
pdf.SetAutoPageBreak(true, 10)
|
||||||
|
pdf.SetMargins(15, 15, 15)
|
||||||
|
|
||||||
|
// Parse markdown to AST
|
||||||
|
reader := text.NewReader([]byte(markdownContent))
|
||||||
|
doc := goldmark.DefaultParser().Parse(reader)
|
||||||
|
|
||||||
|
// Walk the AST and render to PDF
|
||||||
|
err := ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||||
|
if !entering {
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch node := n.(type) {
|
||||||
|
case *ast.Heading:
|
||||||
|
renderHeading(pdf, node, reader)
|
||||||
|
case *ast.Paragraph:
|
||||||
|
renderParagraph(pdf, node, reader)
|
||||||
|
case *ast.List:
|
||||||
|
// Lists are handled by their items
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
case *ast.ListItem:
|
||||||
|
renderListItem(pdf, node, reader)
|
||||||
|
case *ast.CodeBlock:
|
||||||
|
renderCodeBlock(pdf, node, reader)
|
||||||
|
case *ast.CodeSpan:
|
||||||
|
renderCodeSpan(pdf, node, reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to render PDF: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure output directory exists
|
||||||
|
if err := os.MkdirAll(outputPath, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create output directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write PDF to file
|
||||||
|
outputFile := filepath.Join(outputPath, name+".pdf")
|
||||||
|
if err := pdf.OutputFileAndClose(outputFile); err != nil {
|
||||||
|
return fmt.Errorf("failed to write PDF: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" -> Written: %s\n", outputFile)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNodeText extracts text content from a node
|
||||||
|
func getNodeText(n ast.Node, reader text.Reader) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||||
|
if textNode, ok := c.(*ast.Text); ok {
|
||||||
|
segment := textNode.Segment
|
||||||
|
sb.Write(reader.Value(segment))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(getNodeText(c, reader))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderHeading renders a heading to the PDF
|
||||||
|
func renderHeading(pdf *fpdf.Fpdf, node *ast.Heading, reader text.Reader) {
|
||||||
|
txt := getNodeText(node, reader)
|
||||||
|
switch node.Level {
|
||||||
|
case 1:
|
||||||
|
pdf.SetFont("Helvetica", "B", 24)
|
||||||
|
pdf.Ln(5)
|
||||||
|
case 2:
|
||||||
|
pdf.SetFont("Helvetica", "B", 18)
|
||||||
|
pdf.Ln(3)
|
||||||
|
case 3:
|
||||||
|
pdf.SetFont("Helvetica", "B", 14)
|
||||||
|
pdf.Ln(2)
|
||||||
|
default:
|
||||||
|
pdf.SetFont("Helvetica", "B", 12)
|
||||||
|
pdf.Ln(1)
|
||||||
|
}
|
||||||
|
pdf.MultiCell(0, 8, txt, "", "", false)
|
||||||
|
pdf.Ln(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderParagraph renders a paragraph to the PDF
|
||||||
|
func renderParagraph(pdf *fpdf.Fpdf, node *ast.Paragraph, reader text.Reader) {
|
||||||
|
txt := getNodeText(node, reader)
|
||||||
|
if txt == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pdf.SetFont("Helvetica", "", 12)
|
||||||
|
pdf.MultiCell(0, 6, txt, "", "", false)
|
||||||
|
pdf.Ln(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderListItem renders a list item to the PDF
|
||||||
|
func renderListItem(pdf *fpdf.Fpdf, node *ast.ListItem, reader text.Reader) {
|
||||||
|
txt := getNodeText(node, reader)
|
||||||
|
if txt == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pdf.SetFont("Helvetica", "", 12)
|
||||||
|
pdf.Cell(5, 6, "- ")
|
||||||
|
pdf.MultiCell(0, 6, txt, "", "", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderCodeBlock renders a code block to the PDF
|
||||||
|
func renderCodeBlock(pdf *fpdf.Fpdf, node *ast.CodeBlock, reader text.Reader) {
|
||||||
|
segments := node.Lines()
|
||||||
|
if segments.Len() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.SetFont("Courier", "", 10)
|
||||||
|
pdf.SetFillColor(240, 240, 240)
|
||||||
|
pdf.Ln(2)
|
||||||
|
|
||||||
|
for i := 0; i < segments.Len(); i++ {
|
||||||
|
segment := segments.At(i)
|
||||||
|
line := string(reader.Value(segment))
|
||||||
|
line = strings.TrimRight(line, "\n\r")
|
||||||
|
pdf.Cell(0, 5, " "+line)
|
||||||
|
pdf.Ln(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.Ln(2)
|
||||||
|
pdf.SetFont("Helvetica", "", 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderCodeSpan renders inline code to the PDF
|
||||||
|
func renderCodeSpan(pdf *fpdf.Fpdf, node *ast.CodeSpan, reader text.Reader) {
|
||||||
|
txt := getNodeText(node, reader)
|
||||||
|
pdf.SetFont("Courier", "", 12)
|
||||||
|
pdf.Cell(0, 6, txt)
|
||||||
|
pdf.SetFont("Helvetica", "", 12)
|
||||||
|
}
|
||||||
71
ralph.sh
Executable file
71
ralph.sh
Executable file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Usage: ./loop.sh [plan] [max_iterations]
|
||||||
|
# Examples:
|
||||||
|
# ./loop.sh # Build mode, unlimited tasks
|
||||||
|
# ./loop.sh 20 # Build mode, max 20 tasks
|
||||||
|
# ./loop.sh plan # Plan mode, unlimited tasks
|
||||||
|
# ./loop.sh plan 5 # Plan mode, max 5 tasks
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
if [ "$1" = "plan" ]; then
|
||||||
|
# Plan mode
|
||||||
|
MODE="plan"
|
||||||
|
PROMPT_FILE="PROMPT_plan.md"
|
||||||
|
MAX_ITERATIONS=${2:-0}
|
||||||
|
elif [[ "$1" =~ ^[0-9]+$ ]]; then
|
||||||
|
# Build mode with max tasks
|
||||||
|
MODE="build"
|
||||||
|
PROMPT_FILE="PROMPT.md"
|
||||||
|
MAX_ITERATIONS=$1
|
||||||
|
else
|
||||||
|
# Build mode, unlimited (no arguments or invalid input)
|
||||||
|
MODE="build"
|
||||||
|
PROMPT_FILE="PROMPT_build.md"
|
||||||
|
MAX_ITERATIONS=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ITERATION=0
|
||||||
|
CURRENT_BRANCH=$(git branch --show-current)
|
||||||
|
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo "Mode: $MODE"
|
||||||
|
echo "Prompt: $PROMPT_FILE"
|
||||||
|
echo "Branch: $CURRENT_BRANCH"
|
||||||
|
[ $MAX_ITERATIONS -gt 0 ] && echo "Max: $MAX_ITERATIONS iterations (number of tasks)"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
|
# Verify prompt file exists
|
||||||
|
if [ ! -f "$PROMPT_FILE" ]; then
|
||||||
|
echo "Error: $PROMPT_FILE not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
if [ $MAX_ITERATIONS -gt 0 ] && [ $ITERATION -ge $MAX_ITERATIONS ]; then
|
||||||
|
echo "Reached max iterations (number of tasks): $MAX_ITERATIONS"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run Ralph iteration with selected prompt
|
||||||
|
# -p: Headless mode (non-interactive, reads from stdin)
|
||||||
|
# --dangerously-skip-permissions: Auto-approve all tool calls (YOLO mode)
|
||||||
|
# --output-format=stream-json: Structured output for logging/monitoring
|
||||||
|
# --model opus: Primary agent uses Opus for complex reasoning (task selection, prioritization)
|
||||||
|
# Can use 'sonnet' in build mode for speed if plan is clear and tasks well-defined
|
||||||
|
# --verbose: Detailed execution logging
|
||||||
|
cat "$PROMPT_FILE" | claude -p \
|
||||||
|
--dangerously-skip-permissions \
|
||||||
|
--output-format=stream-json \
|
||||||
|
--verbose
|
||||||
|
|
||||||
|
# Push changes after each iteration
|
||||||
|
git push origin "$CURRENT_BRANCH" || {
|
||||||
|
echo "Failed to push. Creating remote branch..."
|
||||||
|
git push -u origin "$CURRENT_BRANCH"
|
||||||
|
}
|
||||||
|
|
||||||
|
ITERATION=$((ITERATION + 1))
|
||||||
|
echo -e "\n\n=================================================================\n"
|
||||||
|
echo -e "\n\n======================== LOOP $ITERATION ========================\n"
|
||||||
|
echo -e "\n\n=================================================================\n"
|
||||||
|
done
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "github.com/gorilla/mux"
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
func (s *WebServer) Routes(r *mux.Router) {
|
func (s *WebServer) Routes(r *mux.Router) {
|
||||||
r.HandleFunc("/version", handleVersion).Methods("GET")
|
r.HandleFunc("/version", handleVersion).Methods("GET")
|
||||||
|
|
||||||
|
// Serve generated HTML files from output directory
|
||||||
|
r.PathPrefix("/").Handler(http.StripPrefix("/", http.FileServer(http.Dir(outputPath))))
|
||||||
}
|
}
|
||||||
|
|||||||
42
themes/default/base.html
Normal file
42
themes/default/base.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin-top: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: #f4f4f4;
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background: #f4f4f4;
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
pre code {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #0066cc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{.Content}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
53
themes/default/baseof.html
Normal file
53
themes/default/baseof.html
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ .Title }}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin-top: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: #f4f4f4;
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background: #f4f4f4;
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
pre code {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
blockquote {
|
||||||
|
border-left: 4px solid #ddd;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1em;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #0066cc;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
{{ .Content }}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
type WebServer struct {
|
type WebServer struct {
|
||||||
HTTPServer *http.Server
|
HTTPServer *http.Server
|
||||||
AppName string `yaml:"app_name"`
|
AppName string `yaml:"app_name"`
|
||||||
|
Theme string `yaml:"theme"`
|
||||||
Listen WSListen `yaml:"listen"`
|
Listen WSListen `yaml:"listen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ func (s *WebServer) Initialize() {
|
|||||||
Port: "80",
|
Port: "80",
|
||||||
}
|
}
|
||||||
s.AppName = "Go Template Container Web Server"
|
s.AppName = "Go Template Container Web Server"
|
||||||
|
s.Theme = "default"
|
||||||
|
|
||||||
// Attempt to read the config file (try both config.yml and config.yaml)
|
// Attempt to read the config file (try both config.yml and config.yaml)
|
||||||
var configFile []byte
|
var configFile []byte
|
||||||
|
|||||||
51
watcher.go
Normal file
51
watcher.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startWatcher starts watching the content directory for changes
|
||||||
|
func startWatcher() (*fsnotify.Watcher, error) {
|
||||||
|
watcher, err := fsnotify.NewWatcher()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create watcher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add content directory to watcher
|
||||||
|
err = watcher.Add(contentPath)
|
||||||
|
if err != nil {
|
||||||
|
watcher.Close()
|
||||||
|
return nil, fmt.Errorf("failed to watch content directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Watching %s for changes...\n", contentPath)
|
||||||
|
|
||||||
|
// Start goroutine to handle events
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case event, ok := <-watcher.Events:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Only process write and create events for .md files
|
||||||
|
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
|
||||||
|
fmt.Printf("File changed: %s\n", event.Name)
|
||||||
|
// Regenerate output
|
||||||
|
if err := generateOutput(); err != nil {
|
||||||
|
fmt.Printf("Error regenerating: %s\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case err, ok := <-watcher.Errors:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("Watcher error: %s\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return watcher, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user