Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,38 @@ gtext cast document.gtext --dry-run

## Project-Specific Guidelines

### Testing Before Push

**MANDATORY**: Always run local tests before pushing to avoid CI failures.

**Quick command**:
```bash
./scripts/test-local.sh
```

**What it tests**:
- ✅ Ruff lint (`ruff check gtext/`)
- ✅ Black formatting (`black --check gtext/`)
- ✅ Pytest with coverage
- ⚠️ Mypy type checking (non-blocking)

**What it CANNOT test**:
- ❌ Windows-specific issues (we're on macOS)

**Manual run** (if script fails):
```bash
# Individual commands
ruff check gtext/
black --check gtext/
pytest --cov=gtext --cov-report=term-missing
mypy gtext/ # Optional, errors won't fail
```

**Auto-fix formatting** (if black check fails):
```bash
black gtext/
```

### When Adding Extensions

1. Create new file in `gtext/extensions/`
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@

---

> 🐦 **Part of the [We-Birds](https://github.com/genropy/we-birds) family**
> *Developer tools that fly together*

## ✨ What is gtext?

gtext is a **universal text processor** with a pluggable extension system. Transform any text file through customizable plugins:
Expand Down
103 changes: 53 additions & 50 deletions gtext/extensions/include.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,46 +13,49 @@
class IncludeExtension(BaseExtension):
"""Extension that processes ```include blocks.

Supports multiple protocols:
- static: Static files (default if no protocol specified)
Supports two types of commands:

Source commands (fetch data):
- static: Static files (must be explicit)
- cli: Execute shell commands
- glob: Multiple files via glob patterns

Supports modifiers (prefix with :modifier:):
- expand: Recursively process the included content
Transform commands (process content):
- render: Recursively process the included content
- tldr: AI-powered summarization
- translate: AI-powered translation

Syntax:
protocol: content # Basic
:expand:protocol: content # With expand modifier
source_command: content # Basic
:transform_command:source_command: content # With transform

Examples:
```include
static: header.md # Include file as-is
:expand:static: template.md.gtext # Include and expand recursively
:render:static: template.md.gtext # Include and render recursively
cli: python get_stats.py # Execute command
:expand:cli: python generate_doc.py # Execute and expand output
:render:cli: python generate_doc.py # Execute and render output
glob: sections/*.md # Include multiple files
footer.md # Implicit static:
```

Backward compatibility: Lines without protocol are treated as static: paths.
Note: static: is now mandatory (no implicit fallback)
"""

name = "include"

# Regex to match ```include blocks
INCLUDE_PATTERN = re.compile(r"```include\s*\n(.*?)```", re.DOTALL | re.MULTILINE)

# Protocol handlers
PROTOCOLS = {
# Source commands (fetch data, require handlers)
SOURCE_COMMANDS = {
"static": "_handle_static",
"cli": "_handle_cli",
"glob": "_handle_glob",
}

# Supported modifiers
MODIFIERS = {
"expand", # Recursively expand included content
# Transform commands (transform content, applied as pipeline)
TRANSFORM_COMMANDS = {
"render", # Recursively process included content (renamed from 'expand')
"tldr", # AI-powered summarization
"translate", # AI-powered translation
}
Expand Down Expand Up @@ -104,29 +107,29 @@ def _resolve_include_block(self, block: str, context: Dict) -> str:
return "\n".join(results)

def _parse_line(self, line: str) -> tuple:
"""Parse line into (modifiers, protocol, content).
"""Parse line into (transforms, source_command, content).

Syntax: :modifier1[params]:modifier2:protocol: content
Syntax: :transform1[params]:transform2:source_command: content

Args:
line: Include directive line

Returns:
Tuple of (list of modifier_specs, protocol name, content)
where modifier_specs can be:
- string: modifier name without parameters
- tuple: (modifier_name, params_dict) with parameters
Tuple of (list of transform_specs, source_command name, content)
where transform_specs can be:
- string: transform name without parameters
- tuple: (transform_name, params_dict) with parameters

Examples:
"static: file.md" → ([], 'static', 'file.md')
":expand:cli: date" → (['expand'], 'cli', 'date')
":render:cli: date" → (['render'], 'cli', 'date')
":translate[it]:static: file.md" → ([('translate', {'lang': 'it'}], 'static', 'file.md')
"file.md" → ([], 'static', 'file.md')
"file.md" → ([], 'unknown', 'file.md') # Error: static: now mandatory
"""
modifiers = []
modifiers = [] # Will contain transform commands
content = line

# Parse modifiers (lines starting with :)
# Parse transforms (lines starting with :)
while content.startswith(":"):
content = content[1:] # Remove leading :

Expand All @@ -149,36 +152,36 @@ def _parse_line(self, line: str) -> tuple:
# For now, simple format: single parameter is the target language
mod_params = {"lang": params_str.strip()}

if mod_name in self.MODIFIERS:
if mod_name in self.TRANSFORM_COMMANDS:
if mod_params:
modifiers.append((mod_name, mod_params))
else:
modifiers.append(mod_name)
content = parts[1]
elif mod_name in self.PROTOCOLS:
# This is a protocol, not a modifier
# Parse as protocol:content
elif mod_name in self.SOURCE_COMMANDS:
# This is a source command, not a transform
# Parse as source_command:content
protocol = mod_name
actual_content = parts[1].strip()
return (modifiers, protocol, actual_content)
else:
# Unknown modifier, stop parsing
# Unknown command, stop parsing
break

# No more modifiers, parse protocol
# No more transforms, parse source command
if ":" in content:
parts = content.split(":", 1)
protocol = parts[0].strip()
actual_content = parts[1].strip()

if protocol in self.PROTOCOLS:
if protocol in self.SOURCE_COMMANDS:
return (modifiers, protocol, actual_content)

# No explicit protocol = static (backward compatibility)
return (modifiers, "static", content.strip())
# No explicit protocol = error (static: is now mandatory)
return (modifiers, "unknown", content.strip())

def _resolve_line(self, line: str, base_dir: Path, context: Dict) -> str:
"""Resolve a single include line using protocol handlers with modifiers support.
"""Resolve a single include line using source command handlers with transform support.

Args:
line: The include directive line
Expand All @@ -188,14 +191,14 @@ def _resolve_line(self, line: str, base_dir: Path, context: Dict) -> str:
Returns:
Resolved content from the line
"""
# Parse modifiers, protocol, content
# Parse transforms, source command, content
modifiers, protocol, content = self._parse_line(line)

# Get handler
if protocol not in self.PROTOCOLS:
return f"<!-- ERROR: Unknown protocol '{protocol}' -->"
if protocol not in self.SOURCE_COMMANDS:
return f"<!-- ERROR: Unknown command '{protocol}' -->"

handler_name = self.PROTOCOLS[protocol]
handler_name = self.SOURCE_COMMANDS[protocol]
handler = getattr(self, handler_name)

# Security check: verify command is allowed
Expand All @@ -207,41 +210,41 @@ def _resolve_line(self, line: str, base_dir: Path, context: Dict) -> str:
# Execute handler
result = handler(content, base_dir, context)

# Apply modifiers
# Apply transform commands
for modifier in modifiers:
# Handle both string modifiers and (name, params) tuples
# Handle both string transforms and (name, params) tuples
if isinstance(modifier, tuple):
mod_name, mod_params = modifier
else:
mod_name = modifier
mod_params = {}

if mod_name == "expand":
if mod_name == "render":
# Recursively process the result
result = self._expand_content(result, base_dir, context)
result = self._render_content(result, base_dir, context)
elif mod_name == "tldr":
# AI-powered summarization
result = self._tldr_content(result, context)
elif mod_name == "translate":
# AI-powered translation
# Merge modifier params into context
# Merge transform params into context
translate_context = context.copy()
if "lang" in mod_params:
translate_context["translate_target"] = mod_params["lang"]
result = self._translate_content(result, translate_context)

return result

def _expand_content(self, content: str, base_dir: Path, context: Dict) -> str:
"""Recursively expand content that may contain ```include blocks.
def _render_content(self, content: str, base_dir: Path, context: Dict) -> str:
"""Recursively render content that may contain ```include blocks.

Args:
content: Content to expand
content: Content to render
base_dir: Base directory for resolution
context: Context dict

Returns:
Expanded content with all ```include blocks resolved
Rendered content with all ```include blocks resolved

Note:
Tracks recursion depth to prevent infinite loops.
Expand All @@ -266,12 +269,12 @@ def replace_include(match):
include_block = match.group(1).strip()
return self._resolve_include_block(include_block, context)

expanded = self.INCLUDE_PATTERN.sub(replace_include, content)
rendered = self.INCLUDE_PATTERN.sub(replace_include, content)

# Restore depth
context["include_depth"] = depth

return expanded
return rendered

def _tldr_content(self, content: str, context: Dict) -> str:
"""Generate AI-powered summary of content.
Expand Down
35 changes: 35 additions & 0 deletions scripts/test-local.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/bin/bash
# Local testing script - run before pushing to avoid CI failures
# This replicates the GitHub Actions CI checks locally

set -e # Exit on first error

echo "🧪 Running local tests..."
echo ""

# Change to project root
cd "$(dirname "$0")/.."

echo "📦 Installing dependencies..."
pip install -e ".[dev]" > /dev/null 2>&1

echo ""
echo "🔍 1/4 Running ruff lint..."
ruff check gtext/

echo ""
echo "🎨 2/4 Checking code formatting with black..."
black --check gtext/

echo ""
echo "🧪 3/4 Running pytest with coverage..."
pytest --cov=gtext --cov-report=term-missing

echo ""
echo "📝 4/4 Running mypy (optional, errors won't fail)..."
mypy gtext/ || echo "⚠️ Mypy found issues (non-blocking)"

echo ""
echo "✅ All local tests passed! Safe to push."
echo ""
echo "⚠️ Note: Windows-specific tests cannot be run locally on macOS"
21 changes: 11 additions & 10 deletions tests/test_include_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ def test_multiple_modifiers_chained(tmp_path):


def test_expand_modifier_with_nested_includes(tmp_path):
"""Test :expand: modifier processes nested includes."""
"""Test :render: modifier processes nested includes."""
# Create nested structure
inner_file = tmp_path / "inner.txt"
inner_file.write_text("Inner content")
Expand All @@ -321,7 +321,7 @@ def test_expand_modifier_with_nested_includes(tmp_path):

processor = TextProcessor()
template = f"""```include
:expand:static: {outer_file}
:render:static: {outer_file}
```"""

result = processor.process_string(template, context={"cwd": tmp_path})
Expand All @@ -332,7 +332,7 @@ def test_expand_modifier_with_nested_includes(tmp_path):


def test_expand_modifier_max_depth_exceeded(tmp_path):
"""Test :expand: respects max_include_depth."""
"""Test :render: respects max_include_depth."""
# Create files with nested includes
file3 = tmp_path / "file3.md"
file3.write_text("Deep content")
Expand All @@ -345,7 +345,7 @@ def test_expand_modifier_max_depth_exceeded(tmp_path):

processor = TextProcessor()
template = f"""```include
:expand:static: {file1}
:render:static: {file1}
```"""

context = {
Expand All @@ -359,18 +359,19 @@ def test_expand_modifier_max_depth_exceeded(tmp_path):
assert "ERROR: Max include depth" in result or "```include" in result


def test_modifier_without_protocol_uses_static(tmp_path):
"""Test modifier without explicit protocol defaults to static."""
def test_modifier_without_protocol_errors(tmp_path):
"""Test modifier without explicit protocol generates error (static: is now mandatory)."""
content_file = tmp_path / "test.txt"
content_file.write_text("Test content here.")

processor = TextProcessor()
# No protocol specified after :expand:
# No protocol specified after :render:
template = f"""```include
:expand:{content_file}
:render:{content_file}
```"""

result = processor.process_string(template, context={"cwd": tmp_path})

# Should treat as :expand:static:
assert "Test content here" in result
# Should generate error (no implicit fallback to static:)
assert "ERROR" in result
assert "Unknown command" in result
Loading
Loading