Skip to content
Merged
5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "texforge"
version = "0.3.0"
version = "0.4.0"
edition = "2021"
rust-version = "1.75"
description = "Self-contained LaTeX to PDF compiler CLI"
Expand Down Expand Up @@ -38,6 +38,9 @@ flate2 = "1.1"
tar = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }

# File watching for --watch mode
notify = { version = "7", features = ["macos_fsevent"] }

# Mermaid diagram rendering
mermaid-rs-renderer = { version = "0.2", default-features = false }
resvg = "0.46"
Expand Down
107 changes: 51 additions & 56 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,13 @@ rm -f ~/.local/bin/texforge # texforge binary
rm -rf ~/.texforge/ # tectonic engine + cached templates
```

---
## Skill

An [texforge Skill](https://skills.sh/jheisonmb/skills/texforge) is available for AI-assisted LaTeX workflows with texforge:

```bash
npx skills add https://github.com/jheisonmb/skills --skill texforge
```

## Quick Start

Expand All @@ -93,6 +99,35 @@ texforge build
texforge clean
```

## Workflow

```mermaid
sequenceDiagram
actor User
participant CLI as texforge
participant Tectonic

User->>CLI: texforge new my-doc
CLI-->>User: project scaffolded

alt Existing LaTeX project
User->>CLI: texforge init
CLI-->>User: project.toml generated
end

User->>CLI: texforge check
CLI-->>User: errors with file:line + suggestion

User->>CLI: texforge fmt
CLI-->>User: .tex files formatted in place

User->>CLI: texforge build
Note over CLI: render embedded diagrams to PNG
CLI->>Tectonic: compile build/main.tex
Note over CLI,Tectonic: auto-installs tectonic on first run
Tectonic-->>User: build/main.pdf
```

---

## Commands
Expand All @@ -103,6 +138,7 @@ texforge clean
| `texforge new <name> -t <template>` | Create with specific template |
| `texforge init` | Initialize texforge in an existing LaTeX project |
| `texforge build` | Compile to PDF |
| `texforge build --watch` | Watch for changes and rebuild automatically |
| `texforge clean` | Remove build artifacts |
| `texforge fmt` | Format .tex files |
| `texforge fmt --check` | Check formatting without modifying |
Expand All @@ -115,61 +151,9 @@ texforge clean

---

## Project Structure

`texforge new` generates this structure:

```
mi-tesis/
├── project.toml # Project configuration
├── main.tex # Entry point
├── sections/ # Document sections
│ └── body.tex
├── bib/
│ └── references.bib # Bibliography
└── assets/
└── images/ # Images and resources
```

### `project.toml`

```toml
[documento]
titulo = "mi-tesis"
autor = "Author"
template = "general"

[compilacion]
entry = "main.tex"
bibliografia = "bib/references.bib"
```

---

## Templates

Templates are managed through the [texforge-templates](https://github.com/JheisonMB/texforge-templates) registry. The `general` template is embedded in the binary and works offline.

| Template | Description |
|---|---|
| `general` | Generic article (default, embedded) |
| `apa-general` | APA 7th edition report |
| `apa-unisalle` | Universidad de La Salle thesis |
| `ieee` | IEEE journal paper |
| `letter` | Formal Spanish correspondence |

```bash
# List installed templates
texforge template list

# Download a template
texforge template add apa-general

# Create project with specific template
texforge new mi-tesis -t apa-general
```

Templates are cached locally in `~/.texforge/templates/` after first download.
Templates are managed through the [texforge-templates](https://github.com/JheisonMB/texforge-templates) registry. The `general` template is embedded in the binary and works offline. Run `texforge template list --all` to see all available templates.

---

Expand Down Expand Up @@ -215,6 +199,19 @@ Both rendered to PNG via pure Rust — no browser, no Node.js, no `dot` binary r

---

## Watch Mode

`texforge build --watch` watches for `.tex` file changes and rebuilds automatically:

```bash
texforge build --watch # rebuild after 10s of inactivity (default)
texforge build --watch --delay 5 # custom delay in seconds
```

The terminal stays open showing build output. Press `Ctrl+C` to stop.

---

## Linter

`texforge check` runs static analysis without compiling:
Expand Down Expand Up @@ -298,8 +295,6 @@ texforge fmt --check # check without modifying (CI-friendly)

---

---

## License

MIT
17 changes: 15 additions & 2 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,14 @@ enum Commands {
template: Option<String>,
},
/// Compile project to PDF
Build,
Build {
/// Watch for file changes and rebuild automatically
#[arg(long)]
watch: bool,
/// Debounce delay in seconds before rebuilding (default: 10)
#[arg(long, default_value = "10")]
delay: u64,
},
/// Format .tex files
Fmt {
/// Check formatting without modifying files
Expand Down Expand Up @@ -66,7 +73,13 @@ impl Cli {
Commands::Clean => commands::clean::execute(),
Commands::Init => commands::init::execute(),
Commands::New { name, template } => commands::new::execute(&name, template.as_deref()),
Commands::Build => commands::build::execute(),
Commands::Build { watch, delay } => {
if watch {
commands::build::watch(delay)
} else {
commands::build::execute()
}
}
Commands::Fmt { check } => commands::fmt::execute(check),
Commands::Check => commands::check::execute(),
Commands::Template { action } => match action {
Expand Down
83 changes: 76 additions & 7 deletions src/commands/build.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
//! `texforge build` command implementation.

use std::sync::mpsc;
use std::time::Duration;

use anyhow::Result;
use notify::{RecursiveMode, Watcher};

use crate::compiler;
use crate::diagrams;
Expand All @@ -9,24 +13,89 @@ use crate::domain::project::Project;
/// Compile project to PDF.
pub fn execute() -> Result<()> {
let project = Project::load()?;

println!("Building project: {}", project.config.documento.titulo);

std::fs::create_dir_all(project.root.join("build"))?;

// Pre-process embedded diagrams — works on copies in build/, originals untouched
diagrams::process(&project.root, &project.config.compilacion.entry)?;

// Compile from build/ — all assets are mirrored there, diagrams use relative paths
let build_dir = project.root.join("build");
let entry_filename = std::path::Path::new(&project.config.compilacion.entry)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or(project.config.compilacion.entry.clone());
compiler::compile(&build_dir, &entry_filename)?;

let pdf_name = std::path::Path::new(&project.config.compilacion.entry).with_extension("pdf");
println!("✅ build/{}", pdf_name.display());
Ok(())
}

/// Watch for .tex file changes and rebuild with debounce.
pub fn watch(delay_secs: u64) -> Result<()> {
let project = Project::load()?;
let debounce = Duration::from_secs(delay_secs);

println!(
"Watching project: {} ({}s debounce — Ctrl+C to stop)",
project.config.documento.titulo, delay_secs
);

// Initial build
run_build(&project);

let (tx, rx) = mpsc::channel();
let mut watcher = notify::recommended_watcher(move |res| {
if let Ok(event) = res {
let _ = tx.send(event);
}
})?;

watcher.watch(&project.root, RecursiveMode::Recursive)?;

let build_dir = project.root.join("build");
let mut pending = false;
let mut last_event = std::time::Instant::now();

loop {
match rx.recv_timeout(Duration::from_millis(500)) {
Ok(event) => {
// Only react to .tex file changes, ignore build/
let relevant = event.paths.iter().any(|p| {
!p.starts_with(&build_dir)
&& p.extension().and_then(|e| e.to_str()) == Some("tex")
});
if relevant {
pending = true;
last_event = std::time::Instant::now();
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(_) => break,
}

if pending && last_event.elapsed() >= debounce {
pending = false;
println!("\n--- rebuilding ---");
run_build(&project);
}
}

Ok(())
}

fn run_build(project: &Project) {
let _ = std::fs::create_dir_all(project.root.join("build"));
if let Err(e) = diagrams::process(&project.root, &project.config.compilacion.entry) {
eprintln!("Error: {}", e);
return;
}
let build_dir = project.root.join("build");
let entry_filename = std::path::Path::new(&project.config.compilacion.entry)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or(project.config.compilacion.entry.clone());
match compiler::compile(&build_dir, &entry_filename) {
Ok(()) => {
let pdf = std::path::Path::new(&project.config.compilacion.entry).with_extension("pdf");
println!("✅ build/{}", pdf.display());
}
Err(e) => eprintln!("Error: {}", e),
}
}
Loading