Hugoifier
feat: package as hugoifier PyPI distribution - Rename src/ → hugoifier/ to make it a proper importable Python package - Add hugoifier/__init__.py and hugoifier/utils/__init__.py - Convert all imports to relative (from ..config, from .module) - Add pyproject.toml with setuptools config, ruff, pytest settings - Add setup.py shim, MANIFEST.in, CI workflow, PyPI publish workflow - Update Dockerfile to use pip install -e . and CMD ["hugoifier"] - Update all tests to use hugoifier.* import paths - Fix all ruff violations: unused imports, unsorted imports, trailing whitespace, long lines in source files - 87 tests passing, ruff clean
Commit
91515c0ce303054cffc92874b3905bea579541de753da7fc6386feb0b6a405b2
Parent
48990c7d7b2ffb5…
47 files changed
+32
+44
+4
-10
+4
+3
+38
+87
+31
+250
+271
+28
+10
+105
+32
+74
+72
+25
+73
+5
-131
-38
-81
-30
-239
-270
-27
-10
-165
-31
-74
-72
-24
+3
-3
+1
-1
+1
-1
+6
-1
+5
-4
+5
-5
+1
-1
+1
-1
+1
-1
+1
-1
+1
-1
+1
-1
+5
-5
+
.github/workflows/ci.yml
+
.github/workflows/publish.yml
~
Dockerfile
+
MANIFEST.in
+
hugoifier/__init__.py
+
hugoifier/cli.py
+
hugoifier/config.py
+
hugoifier/utils/__init__.py
+
hugoifier/utils/analyze.py
+
hugoifier/utils/cloudflare.py
+
hugoifier/utils/complete.py
+
hugoifier/utils/decapify.py
+
hugoifier/utils/deploy.py
+
hugoifier/utils/generate_decap_config.py
+
hugoifier/utils/hugoify.py
+
hugoifier/utils/parser.py
+
hugoifier/utils/theme_finder.py
+
hugoifier/utils/theme_patcher.py
+
hugoifier/utils/translate.py
+
pyproject.toml
+
setup.py
-
src/cli.py
-
src/config.py
-
src/utils/analyze.py
-
src/utils/cloudflare.py
-
src/utils/complete.py
-
src/utils/decapify.py
-
src/utils/deploy.py
-
src/utils/generate_decap_config.py
-
src/utils/hugoify.py
-
src/utils/parser.py
-
src/utils/theme_finder.py
-
src/utils/theme_patcher.py
-
src/utils/translate.py
~
tests/conftest.py
~
tests/test_analyze.py
~
tests/test_cloudflare.py
~
tests/test_complete.py
~
tests/test_config.py
~
tests/test_decapify.py
~
tests/test_deploy.py
~
tests/test_generate_decap_config.py
~
tests/test_hugoify.py
~
tests/test_parser.py
~
tests/test_theme_finder.py
~
tests/test_theme_patcher.py
~
tests/test_translate.py
+32
| --- a/.github/workflows/ci.yml | ||
| +++ b/.github/workflows/ci.yml | ||
| @@ -0,0 +1,32 @@ | ||
| 1 | +name: CI | |
| 2 | + | |
| 3 | +on: | |
| 4 | + push: | |
| 5 | + branches: [main] | |
| 6 | + pull_request: | |
| 7 | + branches: [main] | |
| 8 | + | |
| 9 | +jobs: | |
| 10 | + test: | |
| 11 | + runs-on: ${{ matrix.os }} | |
| 12 | + strategy: | |
| 13 | + matrix:, macosmatrix: | |
| 14 | + os: [ubuntu-latest] | |
| 15 | + python-version: ["3.11", "3.12", "3.13"] | |
| 16 | + steps: | |
| 17 | + - uses: actions/checkout@v4 | |
| 18 | + | |
| 19 | + - uses: actions/setup-python@v5 | |
| 20 | + with: | |
| 21 | + python-version: ${{ matrix.python-version }} | |
| 22 | + | |
| 23 | + - name: Install dependencies | |
| 24 | + run: | | |
| 25 | + python -m pip install --upgrade pip | |
| 26 | + pip install -e ".[dev]" | |
| 27 | + | |
| 28 | + - name: Run tests | |
| 29 | + run: pytest tests/ -v --tb=short | |
| 30 | + | |
| 31 | + - name: Lint | |
| 32 | + run: ru |
| --- a/.github/workflows/ci.yml | |
| +++ b/.github/workflows/ci.yml | |
| @@ -0,0 +1,32 @@ | |
| --- a/.github/workflows/ci.yml | |
| +++ b/.github/workflows/ci.yml | |
| @@ -0,0 +1,32 @@ | |
| 1 | name: CI |
| 2 | |
| 3 | on: |
| 4 | push: |
| 5 | branches: [main] |
| 6 | pull_request: |
| 7 | branches: [main] |
| 8 | |
| 9 | jobs: |
| 10 | test: |
| 11 | runs-on: ${{ matrix.os }} |
| 12 | strategy: |
| 13 | matrix:, macosmatrix: |
| 14 | os: [ubuntu-latest] |
| 15 | python-version: ["3.11", "3.12", "3.13"] |
| 16 | steps: |
| 17 | - uses: actions/checkout@v4 |
| 18 | |
| 19 | - uses: actions/setup-python@v5 |
| 20 | with: |
| 21 | python-version: ${{ matrix.python-version }} |
| 22 | |
| 23 | - name: Install dependencies |
| 24 | run: | |
| 25 | python -m pip install --upgrade pip |
| 26 | pip install -e ".[dev]" |
| 27 | |
| 28 | - name: Run tests |
| 29 | run: pytest tests/ -v --tb=short |
| 30 | |
| 31 | - name: Lint |
| 32 | run: ru |
| --- a/.github/workflows/publish.yml | ||
| +++ b/.github/workflows/publish.yml | ||
| @@ -0,0 +1,44 @@ | ||
| 1 | +name: Publish to PyPI | |
| 2 | + | |
| 3 | +on: | |
| 4 | + release: | |
| 5 | + types: [published] | |
| 6 | + | |
| 7 | +permissions: | |
| 8 | + id-token: write | |
| 9 | + | |
| 10 | +jobs: | |
| 11 | + build: | |
| 12 | + runs-on: ubuntu-latest | |
| 13 | + steps: | |
| 14 | + - uses: actions/checkout@v4 | |
| 15 | + | |
| 16 | + - uses: actions/setup-python@v5 | |
| 17 | + with: | |
| 18 | + python-version: "3.12" | |
| 19 | + | |
| 20 | + - name: Install build tools | |
| 21 | + run: pip install build | |
| 22 | + | |
| 23 | + - name: Build package | |
| 24 | + run: python -m build | |
| 25 | + | |
| 26 | + - name: Upload artifacts | |
| 27 | + uses: actions/upload-artifact@v4 | |
| 28 | + with: | |
| 29 | + name: dist | |
| 30 | + path: dist/ | |
| 31 | + | |
| 32 | + publish-pypi: | |
| 33 | + needs: build | |
| 34 | + runs-on: ubuntu-latest | |
| 35 | + environment: pypi | |
| 36 | + steps: | |
| 37 | + - name: Download artifacts | |
| 38 | + uses: actions/download-artifact@v4 | |
| 39 | + with: | |
| 40 | + name: dist | |
| 41 | + path: dist/ | |
| 42 | + | |
| 43 | + - name: Publish to PyPI | |
| 44 | + uses: pypa/gh-action-pypi-publish@release/v1 |
| --- a/.github/workflows/publish.yml | |
| +++ b/.github/workflows/publish.yml | |
| @@ -0,0 +1,44 @@ | |
| --- a/.github/workflows/publish.yml | |
| +++ b/.github/workflows/publish.yml | |
| @@ -0,0 +1,44 @@ | |
| 1 | name: Publish to PyPI |
| 2 | |
| 3 | on: |
| 4 | release: |
| 5 | types: [published] |
| 6 | |
| 7 | permissions: |
| 8 | id-token: write |
| 9 | |
| 10 | jobs: |
| 11 | build: |
| 12 | runs-on: ubuntu-latest |
| 13 | steps: |
| 14 | - uses: actions/checkout@v4 |
| 15 | |
| 16 | - uses: actions/setup-python@v5 |
| 17 | with: |
| 18 | python-version: "3.12" |
| 19 | |
| 20 | - name: Install build tools |
| 21 | run: pip install build |
| 22 | |
| 23 | - name: Build package |
| 24 | run: python -m build |
| 25 | |
| 26 | - name: Upload artifacts |
| 27 | uses: actions/upload-artifact@v4 |
| 28 | with: |
| 29 | name: dist |
| 30 | path: dist/ |
| 31 | |
| 32 | publish-pypi: |
| 33 | needs: build |
| 34 | runs-on: ubuntu-latest |
| 35 | environment: pypi |
| 36 | steps: |
| 37 | - name: Download artifacts |
| 38 | uses: actions/download-artifact@v4 |
| 39 | with: |
| 40 | name: dist |
| 41 | path: dist/ |
| 42 | |
| 43 | - name: Publish to PyPI |
| 44 | uses: pypa/gh-action-pypi-publish@release/v1 |
+4
-10
| --- Dockerfile | ||
| +++ Dockerfile | ||
| @@ -5,19 +5,13 @@ | ||
| 5 | 5 | WORKDIR /app |
| 6 | 6 | |
| 7 | 7 | # Copy the requirements file into the container |
| 8 | 8 | COPY requirements.txt ./ |
| 9 | 9 | |
| 10 | -# Install any needed packages specified in requirements.txt | |
| 11 | -RUN pip install --no-cache-dir -r requirements.txt | |
| 10 | +# Install package and dependencies | |
| 11 | +COPY pyproject.toml ./ | |
| 12 | +RUN pip install --no-cache-dir -e . | |
| 12 | 13 | |
| 13 | 14 | # Copy the rest of the application code into the container |
| 14 | 15 | COPY . . |
| 15 | 16 | |
| 16 | -# Expose any necessary ports (if your application uses them) | |
| 17 | -# EXPOSE 8000 | |
| 18 | - | |
| 19 | -# Set environment variable for OpenAI API key (you may also pass this at runtime) | |
| 20 | -# ENV OPENAI_API_KEY=your_openai_api_key | |
| 21 | - | |
| 22 | -# Define the command to run your application | |
| 23 | -CMD ["python3", "src/cli.py"] | |
| 17 | +CMD ["hugoifier"] | |
| 24 | 18 | |
| 25 | 19 | ADDED MANIFEST.in |
| 26 | 20 | ADDED hugoifier/__init__.py |
| 27 | 21 | ADDED hugoifier/cli.py |
| 28 | 22 | ADDED hugoifier/config.py |
| 29 | 23 | ADDED hugoifier/utils/__init__.py |
| 30 | 24 | ADDED hugoifier/utils/analyze.py |
| 31 | 25 | ADDED hugoifier/utils/cloudflare.py |
| 32 | 26 | ADDED hugoifier/utils/complete.py |
| 33 | 27 | ADDED hugoifier/utils/decapify.py |
| 34 | 28 | ADDED hugoifier/utils/deploy.py |
| 35 | 29 | ADDED hugoifier/utils/generate_decap_config.py |
| 36 | 30 | ADDED hugoifier/utils/hugoify.py |
| 37 | 31 | ADDED hugoifier/utils/parser.py |
| 38 | 32 | ADDED hugoifier/utils/theme_finder.py |
| 39 | 33 | ADDED hugoifier/utils/theme_patcher.py |
| 40 | 34 | ADDED hugoifier/utils/translate.py |
| 41 | 35 | ADDED pyproject.toml |
| 42 | 36 | ADDED setup.py |
| 43 | 37 | DELETED src/cli.py |
| 44 | 38 | DELETED src/config.py |
| 45 | 39 | DELETED src/utils/analyze.py |
| 46 | 40 | DELETED src/utils/cloudflare.py |
| 47 | 41 | DELETED src/utils/complete.py |
| 48 | 42 | DELETED src/utils/decapify.py |
| 49 | 43 | DELETED src/utils/deploy.py |
| 50 | 44 | DELETED src/utils/generate_decap_config.py |
| 51 | 45 | DELETED src/utils/hugoify.py |
| 52 | 46 | DELETED src/utils/parser.py |
| 53 | 47 | DELETED src/utils/theme_finder.py |
| 54 | 48 | DELETED src/utils/theme_patcher.py |
| 55 | 49 | DELETED src/utils/translate.py |
| --- Dockerfile | |
| +++ Dockerfile | |
| @@ -5,19 +5,13 @@ | |
| 5 | WORKDIR /app |
| 6 | |
| 7 | # Copy the requirements file into the container |
| 8 | COPY requirements.txt ./ |
| 9 | |
| 10 | # Install any needed packages specified in requirements.txt |
| 11 | RUN pip install --no-cache-dir -r requirements.txt |
| 12 | |
| 13 | # Copy the rest of the application code into the container |
| 14 | COPY . . |
| 15 | |
| 16 | # Expose any necessary ports (if your application uses them) |
| 17 | # EXPOSE 8000 |
| 18 | |
| 19 | # Set environment variable for OpenAI API key (you may also pass this at runtime) |
| 20 | # ENV OPENAI_API_KEY=your_openai_api_key |
| 21 | |
| 22 | # Define the command to run your application |
| 23 | CMD ["python3", "src/cli.py"] |
| 24 | |
| 25 | DDED MANIFEST.in |
| 26 | DDED hugoifier/__init__.py |
| 27 | DDED hugoifier/cli.py |
| 28 | DDED hugoifier/config.py |
| 29 | DDED hugoifier/utils/__init__.py |
| 30 | DDED hugoifier/utils/analyze.py |
| 31 | DDED hugoifier/utils/cloudflare.py |
| 32 | DDED hugoifier/utils/complete.py |
| 33 | DDED hugoifier/utils/decapify.py |
| 34 | DDED hugoifier/utils/deploy.py |
| 35 | DDED hugoifier/utils/generate_decap_config.py |
| 36 | DDED hugoifier/utils/hugoify.py |
| 37 | DDED hugoifier/utils/parser.py |
| 38 | DDED hugoifier/utils/theme_finder.py |
| 39 | DDED hugoifier/utils/theme_patcher.py |
| 40 | DDED hugoifier/utils/translate.py |
| 41 | DDED pyproject.toml |
| 42 | DDED setup.py |
| 43 | ELETED src/cli.py |
| 44 | ELETED src/config.py |
| 45 | ELETED src/utils/analyze.py |
| 46 | ELETED src/utils/cloudflare.py |
| 47 | ELETED src/utils/complete.py |
| 48 | ELETED src/utils/decapify.py |
| 49 | ELETED src/utils/deploy.py |
| 50 | ELETED src/utils/generate_decap_config.py |
| 51 | ELETED src/utils/hugoify.py |
| 52 | ELETED src/utils/parser.py |
| 53 | ELETED src/utils/theme_finder.py |
| 54 | ELETED src/utils/theme_patcher.py |
| 55 | ELETED src/utils/translate.py |
| --- Dockerfile | |
| +++ Dockerfile | |
| @@ -5,19 +5,13 @@ | |
| 5 | WORKDIR /app |
| 6 | |
| 7 | # Copy the requirements file into the container |
| 8 | COPY requirements.txt ./ |
| 9 | |
| 10 | # Install package and dependencies |
| 11 | COPY pyproject.toml ./ |
| 12 | RUN pip install --no-cache-dir -e . |
| 13 | |
| 14 | # Copy the rest of the application code into the container |
| 15 | COPY . . |
| 16 | |
| 17 | CMD ["hugoifier"] |
| 18 | |
| 19 | DDED MANIFEST.in |
| 20 | DDED hugoifier/__init__.py |
| 21 | DDED hugoifier/cli.py |
| 22 | DDED hugoifier/config.py |
| 23 | DDED hugoifier/utils/__init__.py |
| 24 | DDED hugoifier/utils/analyze.py |
| 25 | DDED hugoifier/utils/cloudflare.py |
| 26 | DDED hugoifier/utils/complete.py |
| 27 | DDED hugoifier/utils/decapify.py |
| 28 | DDED hugoifier/utils/deploy.py |
| 29 | DDED hugoifier/utils/generate_decap_config.py |
| 30 | DDED hugoifier/utils/hugoify.py |
| 31 | DDED hugoifier/utils/parser.py |
| 32 | DDED hugoifier/utils/theme_finder.py |
| 33 | DDED hugoifier/utils/theme_patcher.py |
| 34 | DDED hugoifier/utils/translate.py |
| 35 | DDED pyproject.toml |
| 36 | DDED setup.py |
| 37 | ELETED src/cli.py |
| 38 | ELETED src/config.py |
| 39 | ELETED src/utils/analyze.py |
| 40 | ELETED src/utils/cloudflare.py |
| 41 | ELETED src/utils/complete.py |
| 42 | ELETED src/utils/decapify.py |
| 43 | ELETED src/utils/deploy.py |
| 44 | ELETED src/utils/generate_decap_config.py |
| 45 | ELETED src/utils/hugoify.py |
| 46 | ELETED src/utils/parser.py |
| 47 | ELETED src/utils/theme_finder.py |
| 48 | ELETED src/utils/theme_patcher.py |
| 49 | ELETED src/utils/translate.py |
+4
| --- a/MANIFEST.in | ||
| +++ b/MANIFEST.in | ||
| @@ -0,0 +1,4 @@ | ||
| 1 | +include LICENSE | |
| 2 | +include README.md | |
| 3 | +include requirements.txt | |
| 4 | +recursive-include hugoifier *.py |
| --- a/MANIFEST.in | |
| +++ b/MANIFEST.in | |
| @@ -0,0 +1,4 @@ | |
| --- a/MANIFEST.in | |
| +++ b/MANIFEST.in | |
| @@ -0,0 +1,4 @@ | |
| 1 | include LICENSE |
| 2 | include README.md |
| 3 | include requirements.txt |
| 4 | recursive-include hugoifier *.py |
| --- a/hugoifier/__init__.py | ||
| +++ b/hugoifier/__init__.py | ||
| @@ -0,0 +1,3 @@ | ||
| 1 | +"""Hugoifier — AI-powered Hugo theme converter with Decap CMS integration.""" | |
| 2 | + | |
| 3 | +__version__ = "0.1.0" |
| --- a/hugoifier/__init__.py | |
| +++ b/hugoifier/__init__.py | |
| @@ -0,0 +1,3 @@ | |
| --- a/hugoifier/__init__.py | |
| +++ b/hugoifier/__init__.py | |
| @@ -0,0 +1,3 @@ | |
| 1 | """Hugoifier — AI-powered Hugo theme converter with Decap CMS integration.""" |
| 2 | |
| 3 | __version__ = "0.1.0" |
No diff available
+38
| --- a/hugoifier/config.py | ||
| +++ b/hugoifier/config.py | ||
| @@ -0,0 +1,38 @@ | ||
| 1 | +""" | |
| 2 | +Multi-backend AI configuration. | |
| 3 | + | |
| 4 | +Set HUGOIFIER_BACKEND env var to switch backends: | |
| 5 | + anthropic (default) — claude-sonnet-4-6 | |
| 6 | + openai — gpt-4-turbo | |
| 7 | + google — gemini-1.5-pro | |
| 8 | + | |
| 9 | +Model can be overridden per-backend: | |
| 10 | + ANTHROPIC_MODEL, OPENAI_MODEL, GOOGLE_MODEL | |
| 11 | +""" | |
| 12 | + | |
| 13 | +import os | |
| 14 | + | |
| 15 | +BACKEND = os.getenv('HUGOIFIER_BACKEND', 'anthropic').lower() | |
| 16 | + | |
| 17 | +# Anthropic settings | |
| 18 | +ANTHROPIC_API_KEY = os.getenv('ANTHROPIC_API_KEY') | |
| 19 | +ANTHROPIC_MODEL = os.getenv('ANTHROPIC_MODEL', 'claude-sonnet-4-6') | |
| 20 | + | |
| 21 | +# OpenAI settings | |
| 22 | +OPENAI_API_KEY = os.getenv('OPENAI_API_KEY') | |
| 23 | +OPENAI_MODEL = os.getenv('OPENAI_MODEL', 'gpt-4-turbo') | |
| 24 | + | |
| 25 | +# Google settings | |
| 26 | +GOOGLE_API_KEY = os.getenv('GOOGLE_API_KEY') | |
| 27 | +GOOGLE_MODEL = os.getenv('GOOGLE_MODEL', 'gemini-1.5-pro') | |
| 28 | + | |
| 29 | +MAX_TOKENS = int(os.getenv('HUGOIFIER_MAX_TOKENS', '4096')) | |
| 30 | + | |
| 31 | + | |
| 32 | +def call_ai(prompt: str, system: str = "You are a helpful Hugo theme conversion assistant."x_tokens or MAX_TOKENS | |
| 33 | + if BACKEND == 'anthropic': | |
| 34 | + return _ca) | |
| 35 | + elif BACKEND == 'openai': | |
| 36 | + return _call_openai(prompt, system) | |
| 37 | + elif BACKEND == 'google': | |
| 38 | + retu |
| --- a/hugoifier/config.py | |
| +++ b/hugoifier/config.py | |
| @@ -0,0 +1,38 @@ | |
| --- a/hugoifier/config.py | |
| +++ b/hugoifier/config.py | |
| @@ -0,0 +1,38 @@ | |
| 1 | """ |
| 2 | Multi-backend AI configuration. |
| 3 | |
| 4 | Set HUGOIFIER_BACKEND env var to switch backends: |
| 5 | anthropic (default) — claude-sonnet-4-6 |
| 6 | openai — gpt-4-turbo |
| 7 | google — gemini-1.5-pro |
| 8 | |
| 9 | Model can be overridden per-backend: |
| 10 | ANTHROPIC_MODEL, OPENAI_MODEL, GOOGLE_MODEL |
| 11 | """ |
| 12 | |
| 13 | import os |
| 14 | |
| 15 | BACKEND = os.getenv('HUGOIFIER_BACKEND', 'anthropic').lower() |
| 16 | |
| 17 | # Anthropic settings |
| 18 | ANTHROPIC_API_KEY = os.getenv('ANTHROPIC_API_KEY') |
| 19 | ANTHROPIC_MODEL = os.getenv('ANTHROPIC_MODEL', 'claude-sonnet-4-6') |
| 20 | |
| 21 | # OpenAI settings |
| 22 | OPENAI_API_KEY = os.getenv('OPENAI_API_KEY') |
| 23 | OPENAI_MODEL = os.getenv('OPENAI_MODEL', 'gpt-4-turbo') |
| 24 | |
| 25 | # Google settings |
| 26 | GOOGLE_API_KEY = os.getenv('GOOGLE_API_KEY') |
| 27 | GOOGLE_MODEL = os.getenv('GOOGLE_MODEL', 'gemini-1.5-pro') |
| 28 | |
| 29 | MAX_TOKENS = int(os.getenv('HUGOIFIER_MAX_TOKENS', '4096')) |
| 30 | |
| 31 | |
| 32 | def call_ai(prompt: str, system: str = "You are a helpful Hugo theme conversion assistant."x_tokens or MAX_TOKENS |
| 33 | if BACKEND == 'anthropic': |
| 34 | return _ca) |
| 35 | elif BACKEND == 'openai': |
| 36 | return _call_openai(prompt, system) |
| 37 | elif BACKEND == 'google': |
| 38 | retu |
No diff available
| --- a/hugoifier/utils/analyze.py | ||
| +++ b/hugoifier/utils/analyze.py | ||
| @@ -0,0 +1,87 @@ | ||
| 1 | +""" | |
| 2 | +Analyzes a Hugo theme or raw HTML theme and reports structure + recommendations. | |
| 3 | +""" | |
| 4 | + | |
| 5 | +import logging | |
| 6 | +import os | |
| 7 | + | |
| 8 | +from ..config import call_ai | |
| 9 | +from .theme_finder import find_hugo_theme, find_raw_html_files | |
| 10 | + | |
| 11 | +SYSTEM = "You are an expert Hugo theme developer analyzing themes for conversion." | |
| 12 | + | |
| 13 | + | |
| 14 | +def analyze(path: str) -> str: | |
| 15 | + logging.info(f"Analyzing {path} ...") | |
| 16 | + | |
| 17 | + info = find_hugo_theme(path) | |
| 18 | + | |
| 19 | + if info: | |
| 20 | + return _analyze_hugo_theme(info) | |
| 21 | + else: | |
| 22 | + return _analyze_raw_html(path) | |
| 23 | + | |
| 24 | + | |
| 25 | +def _analyze_hugo_theme(info: dict) -> str: | |
| 26 | + theme_dir = info['theme_dir'] | |
| 27 | + theme_name = info['theme_name'] | |
| 28 | + example_site = info['example_site'] | |
| 29 | + | |
| 30 | + # Collect layout files | |
| 31 | + layouts = [] | |
| 32 | + for root, dirs, files in os.walk(os.path.join(theme_dir, 'layouts')): | |
| 33 | + for f in files: | |
| 34 | + if f.endswith('.html'): | |
| 35 | + rel = os.path.relpath(os.path.join(root, f), theme_dir) | |
| 36 | + layouts.append(rel) | |
| 37 | + | |
| 38 | + # Collect content types from exampleSite | |
| 39 | + content_types = [] | |
| 40 | + if example_site: | |
| 41 | + content_dir = os.path.join(example_site, 'content') | |
| 42 | + if os.path.isdir(content_dir): | |
| 43 | + content_types = [ | |
| 44 | + d for d in os.listdir(content_dir) | |
| 45 | + if os.path.isdir(os.path.join(content_dir, d)) | |
| 46 | + ] | |
| 47 | + | |
| 48 | + report = [ | |
| 49 | + f"Theme: {theme_name}", | |
| 50 | + f"Theme dir: {theme_dir}", | |
| 51 | + f"Layouts ({len(layouts)}):", | |
| 52 | + *[f" {layout}" for layout in sorted(layouts)], | |
| 53 | + f"Content types: {content_types}", | |
| 54 | + f"ExampleSite: {example_site or 'none'}", | |
| 55 | + "", | |
| 56 | + "Status: Already a Hugo theme. Use 'complete' to assemble a working site.", | |
| 57 | + ] | |
| 58 | + return "\n".join(report) | |
| 59 | + | |
| 60 | + | |
| 61 | +def _analyze_raw_html(path: str) -> str: | |
| 62 | + html_files = find_raw_html_files(path) | |
| 63 | + if not html_files: | |
| 64 | + return f"No HTML files found at {path}" | |
| 65 | + | |
| 66 | + # Read main HTML file for AI analysis | |
| 67 | + main = next( | |
| 68 | + (f for f in html_files if os.path.basename(f).lower() == 'index.html'), | |
| 69 | + html_files[0], | |
| 70 | + ) | |
| 71 | + with open(main, 'r', errors='replace') as f: | |
| 72 | + html = f.read()[:20000] | |
| 73 | + | |
| 74 | + prompt = f"""Analyze this HTML theme file and provide: | |
| 75 | +1. Identified reusable components (header, footer, nav, sidebar, etc.) | |
| 76 | +2. Recommended Hugo template tags for dynamic content | |
| 77 | +3. Suggested partial splits | |
| 78 | +4. Content sections that should be data/ YAML files for Decap CMS | |
| 79 | + | |
| 80 | +HTML: | |
| 81 | +{html}""" | |
| 82 | + | |
| 83 | + try: | |
| 84 | + return call_ai(prompt, SYSTEM) | |
| 85 | + except Exception as e: | |
| 86 | + logging.error(f"AI analysis failed: {e}") | |
| 87 | + return f"HTML theme with {len(html_files)} files. AI analysis failed: {e}" |
| --- a/hugoifier/utils/analyze.py | |
| +++ b/hugoifier/utils/analyze.py | |
| @@ -0,0 +1,87 @@ | |
| --- a/hugoifier/utils/analyze.py | |
| +++ b/hugoifier/utils/analyze.py | |
| @@ -0,0 +1,87 @@ | |
| 1 | """ |
| 2 | Analyzes a Hugo theme or raw HTML theme and reports structure + recommendations. |
| 3 | """ |
| 4 | |
| 5 | import logging |
| 6 | import os |
| 7 | |
| 8 | from ..config import call_ai |
| 9 | from .theme_finder import find_hugo_theme, find_raw_html_files |
| 10 | |
| 11 | SYSTEM = "You are an expert Hugo theme developer analyzing themes for conversion." |
| 12 | |
| 13 | |
| 14 | def analyze(path: str) -> str: |
| 15 | logging.info(f"Analyzing {path} ...") |
| 16 | |
| 17 | info = find_hugo_theme(path) |
| 18 | |
| 19 | if info: |
| 20 | return _analyze_hugo_theme(info) |
| 21 | else: |
| 22 | return _analyze_raw_html(path) |
| 23 | |
| 24 | |
| 25 | def _analyze_hugo_theme(info: dict) -> str: |
| 26 | theme_dir = info['theme_dir'] |
| 27 | theme_name = info['theme_name'] |
| 28 | example_site = info['example_site'] |
| 29 | |
| 30 | # Collect layout files |
| 31 | layouts = [] |
| 32 | for root, dirs, files in os.walk(os.path.join(theme_dir, 'layouts')): |
| 33 | for f in files: |
| 34 | if f.endswith('.html'): |
| 35 | rel = os.path.relpath(os.path.join(root, f), theme_dir) |
| 36 | layouts.append(rel) |
| 37 | |
| 38 | # Collect content types from exampleSite |
| 39 | content_types = [] |
| 40 | if example_site: |
| 41 | content_dir = os.path.join(example_site, 'content') |
| 42 | if os.path.isdir(content_dir): |
| 43 | content_types = [ |
| 44 | d for d in os.listdir(content_dir) |
| 45 | if os.path.isdir(os.path.join(content_dir, d)) |
| 46 | ] |
| 47 | |
| 48 | report = [ |
| 49 | f"Theme: {theme_name}", |
| 50 | f"Theme dir: {theme_dir}", |
| 51 | f"Layouts ({len(layouts)}):", |
| 52 | *[f" {layout}" for layout in sorted(layouts)], |
| 53 | f"Content types: {content_types}", |
| 54 | f"ExampleSite: {example_site or 'none'}", |
| 55 | "", |
| 56 | "Status: Already a Hugo theme. Use 'complete' to assemble a working site.", |
| 57 | ] |
| 58 | return "\n".join(report) |
| 59 | |
| 60 | |
| 61 | def _analyze_raw_html(path: str) -> str: |
| 62 | html_files = find_raw_html_files(path) |
| 63 | if not html_files: |
| 64 | return f"No HTML files found at {path}" |
| 65 | |
| 66 | # Read main HTML file for AI analysis |
| 67 | main = next( |
| 68 | (f for f in html_files if os.path.basename(f).lower() == 'index.html'), |
| 69 | html_files[0], |
| 70 | ) |
| 71 | with open(main, 'r', errors='replace') as f: |
| 72 | html = f.read()[:20000] |
| 73 | |
| 74 | prompt = f"""Analyze this HTML theme file and provide: |
| 75 | 1. Identified reusable components (header, footer, nav, sidebar, etc.) |
| 76 | 2. Recommended Hugo template tags for dynamic content |
| 77 | 3. Suggested partial splits |
| 78 | 4. Content sections that should be data/ YAML files for Decap CMS |
| 79 | |
| 80 | HTML: |
| 81 | {html}""" |
| 82 | |
| 83 | try: |
| 84 | return call_ai(prompt, SYSTEM) |
| 85 | except Exception as e: |
| 86 | logging.error(f"AI analysis failed: {e}") |
| 87 | return f"HTML theme with {len(html_files)} files. AI analysis failed: {e}" |
| --- a/hugoifier/utils/cloudflare.py | ||
| +++ b/hugoifier/utils/cloudflare.py | ||
| @@ -0,0 +1,31 @@ | ||
| 1 | +""" | |
| 2 | +Configures and deploys a Hugo site to Cloudflare, handling page creation, | |
| 3 | +DNS settings, and deployment via the Cloudflare API. | |
| 4 | +""" | |
| 5 | + | |
| 6 | +import logging | |
| 7 | + | |
| 8 | + | |
| 9 | +# Function to configure and deploy to Cloudflare | |
| 10 | +def configure_cloudflare(path, zone): | |
| 11 | + logging.info(f"Starting Cloudflare configuration for {path} in zone {zone}...") | |
| 12 | + try: | |
| 13 | + # Placeholder logic for Cloudflare configuration | |
| 14 | + # This could involve API calls to Cloudflare to create pages, set DNS, etc. | |
| 15 | + logging.info("Creating Cloudflare page...") | |
| 16 | + # Example API call to create a page | |
| 17 | + # cloudflare_api.create_page(path, zone) | |
| 18 | + | |
| 19 | + logging.info("Deploying site...") | |
| 20 | + # Example API call to deploy the site | |
| 21 | + # cloudflare_api.deploy_site(path, zone) | |
| 22 | + | |
| 23 | + logging.info("Configuring DNS settings...") | |
| 24 | + # Example API call to configure DNS | |
| 25 | + # cloudflare_api.configure_dns(path, zone) | |
| 26 | + | |
| 27 | + logging.info("Cloudflare configuration complete.") | |
| 28 | + return "Cloudflare configuration complete" | |
| 29 | + except Exception as e: | |
| 30 | + logging.error(f"Error during Cloudflare configuration: {e}") | |
| 31 | + return "Cloudflare configuration failed" |
| --- a/hugoifier/utils/cloudflare.py | |
| +++ b/hugoifier/utils/cloudflare.py | |
| @@ -0,0 +1,31 @@ | |
| --- a/hugoifier/utils/cloudflare.py | |
| +++ b/hugoifier/utils/cloudflare.py | |
| @@ -0,0 +1,31 @@ | |
| 1 | """ |
| 2 | Configures and deploys a Hugo site to Cloudflare, handling page creation, |
| 3 | DNS settings, and deployment via the Cloudflare API. |
| 4 | """ |
| 5 | |
| 6 | import logging |
| 7 | |
| 8 | |
| 9 | # Function to configure and deploy to Cloudflare |
| 10 | def configure_cloudflare(path, zone): |
| 11 | logging.info(f"Starting Cloudflare configuration for {path} in zone {zone}...") |
| 12 | try: |
| 13 | # Placeholder logic for Cloudflare configuration |
| 14 | # This could involve API calls to Cloudflare to create pages, set DNS, etc. |
| 15 | logging.info("Creating Cloudflare page...") |
| 16 | # Example API call to create a page |
| 17 | # cloudflare_api.create_page(path, zone) |
| 18 | |
| 19 | logging.info("Deploying site...") |
| 20 | # Example API call to deploy the site |
| 21 | # cloudflare_api.deploy_site(path, zone) |
| 22 | |
| 23 | logging.info("Configuring DNS settings...") |
| 24 | # Example API call to configure DNS |
| 25 | # cloudflare_api.configure_dns(path, zone) |
| 26 | |
| 27 | logging.info("Cloudflare configuration complete.") |
| 28 | return "Cloudflare configuration complete" |
| 29 | except Exception as e: |
| 30 | logging.error(f"Error during Cloudflare configuration: {e}") |
| 31 | return "Cloudflare configuration failed" |
+250
| --- a/hugoifier/utils/complete.py | ||
| +++ b/hugoifier/utils/complete.py | ||
| @@ -0,0 +1,250 @@ | ||
| 1 | +""" | |
| 2 | +Full end-to-end pipeline: detect → copy → configure → decap. | |
| 3 | + | |
| 4 | +For already-Hugo themes: assembles a clean, standalone site. | |
| 5 | +For raw HTML themes: calls hugoify first, then assembles. | |
| 6 | +""" | |
| 7 | + | |
| 8 | +import logging | |
| 9 | +import os | |
| 10 | +import shutil | |
| 11 | +from pathlib import Path | |
| 12 | + | |
| 13 | +from .decapify import decapify | |
| 14 | +from .hugoify import hugoify_html | |
| 15 | +from .theme_finder imraw_html_files | |
| 16 | +from .theme_patcher import patch_config, patch_theme | |
| 17 | + | |
| 18 | + | |
| 19 | +def complete( | |
| 20 | + input_path: str, | |
| 21 | + output_dir: str = None, | |
| 22 | + cms_name: str = None, | |
| 23 | + cms_logo: str = None, | |
| 24 | + cms_color: str = None, | |
| 25 | +) -> str: | |
| 26 | + """ | |
| 27 | + Run the full pipeline for a theme. | |
| 28 | + | |
| 29 | + Args: | |
| 30 | + input_path: Path to a theme directory (from themes/) or raw HTML dir. | |
| 31 | + output_dir: Where to write the output site. Defaults to output/{theme-name}. | |
| 32 | + cms_name: Whitelabel CMS name for Decap admin UI. | |
| 33 | + cms_logo: Whitelabel logo URL for Decap admin UI. | |
| 34 | + cms_color: Whitelabel top-bar color for Decap admin UI. | |
| 35 | + | |
| 36 | + Returns: | |
| 37 | + Path to the generated site, or error message. | |
| 38 | + """ | |
| 39 | + logging.info(f"Starting pipeline for {input_path} ...") | |
| 40 | + | |
| 41 | + branding = {'cms_name': cms_name, 'cms_logo': cms_logo, 'cms_color': cms_color} | |
| 42 | + info = find_hugo_theme(input_path) | |
| 43 | + | |
| 44 | + if info: | |
| 45 | + return _assemble_hugo_site(info, output_dir, branding) | |
| 46 | +t_raw_html(input_p# Raw HTML path | |
| 47 | + html_files = find_raw_ if not html_files: | |
| 48 | + or HTML files fh) | |
| 49 | + if nextjs_infraw_html(input_path, html_files, output_dir, branding) | |
| 50 | + | |
| 51 | + | |
| 52 | +# --------------------------------------------------------------------------- | |
| 53 | +# Hugo theme path | |
| 54 | +# --------------------------------------------------------------------------- | |
| 55 | + | |
| 56 | +def _assemble_hugo_site(info: dict, output_dir: str = None, branding: dict = None) -> str: | |
| 57 | + theme_dir = info['theme_dir'] | |
| 58 | + example_site = info['example_site'] | |
| 59 | + theme_name = info['theme_name'] | |
| 60 | + | |
| 61 | + if output_dir is None: | |
| 62 | + output_dir = str(Path(__file__).parents[2] / 'output' / theme_name) | |
| 63 | + | |
| 64 | + logging.info(f"Building site at {output_dir} ...") | |
| 65 | + os.makedirs(output_dir, exist_ok=True) | |
| 66 | + | |
| 67 | + # 1. Copy theme files → themes/{theme_name}/ | |
| 68 | + dest_theme = os.path.join(output_dir, 'themes', theme_name) | |
| 69 | + _copy_dir(theme_dir, dest_theme, exclude={'exampleSite', '__MACOSX', '.DS_Store'}) | |
| 70 | + logging.info(f"Copied theme to {dest_theme}") | |
| 71 | + patch_theme(dest_themy-Hugo themes: assembles a clean, standalone site. | |
| 72 | +For raw HTML themes: calls hugoify first, then assembles. | |
| 73 | +""" | |
| 74 | + | |
| 75 | +import logging | |
| 76 | +import os | |
| 77 | +import shutil | |
| 78 | +from pathlib import Path | |
| 79 | + | |
| 80 | +from .decapify import decapify | |
| 81 | +from .hugoify import hugoify_html, hugoify_nextjs | |
| 82 | +from .theme_finder import find_hugo_theme, find_nextjs_app, find_raw_html_files | |
| 83 | +from .theme_patcher import patch_config, patch_theme | |
| 84 | + | |
| 85 | + | |
| 86 | +def complete( | |
| 87 | + input_path: str, | |
| 88 | + output_dir: str = None, | |
| 89 | + cms_name: str = None, | |
| 90 | + cms_logo: str = None, | |
| 91 | + cms_color: str = None, | |
| 92 | +) -> str: | |
| 93 | + """ | |
| 94 | + Run the full pipeline for a theme. | |
| 95 | + | |
| 96 | + Args: | |
| 97 | + input_path: Path to a theme directory (from themes/) or raw HTML dir. | |
| 98 | + output_dir: Where to write the output site. Defaults to output/{theme-name}. | |
| 99 | + cms_name: Whitelabel CMS name for Decap admin UI. | |
| 100 | + cms_logo: Whitelabel logo URL for Decap admin UI. | |
| 101 | + cms_color: Whitelabel top-bar color for Decap admin UI. | |
| 102 | + | |
| 103 | + Returns: | |
| 104 | + Path to the generated site, or error message. | |
| 105 | + """ | |
| 106 | + logging.info(f"Starting pipeline for {input_path} ...") | |
| 107 | + | |
| 108 | + branding = {'cms_name': cms_name, 'cms_logo': cms_logo, 'cms_color': cms_color} | |
| 109 | + info = find_hugo_theme(input_path) | |
| 110 | + | |
| 111 | + if info: | |
| 112 | + return _assemble_hugo_site(info, output_dir, branding) | |
| 113 | + | |
| 114 | + # Next.js path (check before raw HTML since Next.js projects may contain .html files) | |
| 115 | + nextjs_info = find_nextjs_app(input_path) | |
| 116 | + if nextjs_info: | |
| 117 | + return _convert_nextjs(input_path, nextjs_info, output_dir, branding) | |
| 118 | + | |
| 119 | + # Raw HTML path | |
| 120 | + html_files = find_raw_html_files(input_path) | |
| 121 | + if not html_files: | |
| 122 | + raise ValueError(f"No Hugo theme, Next.js app, or HTML files found in {input_path}") | |
| 123 | + return _convert_raw_html(input_path, html_files, output_dir, branding) | |
| 124 | + | |
| 125 | + | |
| 126 | +# --------------------------------------------------------------------------- | |
| 127 | +# Hugo theme path | |
| 128 | +# --------------------------------------------------------------------------- | |
| 129 | + | |
| 130 | +def _assemble_hugo_site(info: dict, output_dir: str = None, branding: dict = None) -> str: | |
| 131 | + theme_dir = info['theme_dir'] | |
| 132 | + example_site = info['example_site'] | |
| 133 | + theme_name = info['theme_name'] | |
| 134 | + | |
| 135 | + if output_dir is None: | |
| 136 | + output_dir = str(Path(__file__).parents[2] / 'output' / theme_name) | |
| 137 | + | |
| 138 | + logging.info(f"Building site at {output_dir} ...") | |
| 139 | + os.makedirs(output_dir, exist_ok=True) | |
| 140 | + | |
| 141 | + # 1. Copy theme files → themes/{theme_name}/ | |
| 142 | + dest_theme = os.path.join(output_dir, 'themes', theme_name) | |
| 143 | + _copy_dir(theme_dir, dest_theme, exclude={'exampleSite', '__MACOSX', '.DS_Store'}) | |
| 144 | + logging.info(f"Copied theme to {dest_theme}") | |
| 145 | + patch_theme(dest_theme) | |
| 146 | + | |
| 147 | + # 2. Copy exampleSite content/static/data → site root | |
| 148 | + if example_site: | |
| 149 | + for subdir in ('content', 'data', 'i18n'): | |
| 150 | + src = os.path.join(example_site, subdir) | |
| 151 | + if os.path.isdir(src): | |
| 152 | + _copy_dir(src, os.path.join(output_dir, subdir)) | |
| 153 | + logging.info(f"Copied {subdir}/ from exampleSite") | |
| 154 | + | |
| 155 | + # Static: merge exampleSite/static into output/static | |
| 156 | + src_static = os.path.join(example_site, 'static') | |
| 157 | + if os.path.isdir(src_static): | |
| 158 | + _copy_dir(src_static, os.path.join(output_dir, 'static')) | |
| 159 | + logging.info("Copied static/ from exampleSite") | |
| 160 | + | |
| 161 | + # Write hugo.toml from exampleSite config | |
| 162 | + config_toml = _find_config(example_site) | |
| 163 | + if config_toml: | |
| 164 | + _write_hugo_toml(config_toml, output_dir, theme_name) | |
| 165 | + else: | |
| 166 | + _write_minimal_hugo_toml(output_dir, theme_name) | |
| 167 | + else: | |
| 168 | + _write_minimal_hugo_toml(output_dir, theme_name) | |
| 169 | + # Create minimal content/_index.md | |
| 170 | + content_dir = os.path.join(output_dir, 'content') | |
| 171 | + os.makedirs(content_dir, exist_ok=True) | |
| 172 | + index_md = os.path.join(content_dir, '_index.md') | |
| 173 | + if not os.path.exists(index_md): | |
| 174 | + with open(index_md, 'w') as f: | |
| 175 | + f.write('---\ntitle: Home\n---\n') | |
| 176 | + | |
| 177 | + # 3. Generate Decap CMS config | |
| 178 | + b = branding or {} | |
| 179 | + decapify( | |
| 180 | + output_dir, | |
| 181 | + cms_name=b.get('cms_name'), cms_logo=b.get('cms_logo'), cms_color=b.get('cms_color'), | |
| 182 | + ) | |
| 183 | + | |
| 184 | + logging.info(f"Done. Site ready at: {output_dir}") | |
| 185 | + logging.info(f"Run: cd {output_dir} && hugo serve") | |
| 186 | + return output_dir | |
| 187 | + | |
| 188 | + | |
| 189 | +# --------------------------------------------------------------------------- | |
| 190 | +# Next.js path | |
| 191 | +# --------------------------------------------------------------------------- | |
| 192 | + | |
| 193 | +def _convert_nextjs( | |
| 194 | + input_path: str, nextjs_info: dict, output_dir: str = None, branding: dict = None | |
| 195 | +) -> st""" | |
| 196 | +Full end-to-end pipeline: detect → copy → configure → decap. | |
| 197 | + | |
| 198 | +For already-Hugo themes: assembles a clean, standalone site. | |
| 199 | +For raw HTML themes: calls hugoify first, then assembles. | |
| 200 | +""" | |
| 201 | + | |
| 202 | +import logging | |
| 203 | +import os | |
| 204 | +import shutil | |
| 205 | +from pathlib import Path | |
| 206 | + | |
| 207 | +from .decapify import decapify | |
| 208 | +from .hugoify import hugoify_html, hugoify_nextjs | |
| 209 | +from .theme_finder import find_hugo_theme, find_nextjs_app, find_raw_html_files | |
| 210 | +from .theme_patcher import patch_config, patch_theme | |
| 211 | + | |
| 212 | + | |
| 213 | +def complete( | |
| 214 | + input_path: str, | |
| 215 | + output_dir: str = None, | |
| 216 | + cms_name: str = None, | |
| 217 | + cms_logo: str = None, | |
| 218 | + cms_color: str = None, | |
| 219 | +) -> str: | |
| 220 | + """ | |
| 221 | + Run the full pipeline for a theme. | |
| 222 | + | |
| 223 | + Args: | |
| 224 | + input_path: Path to a theme directory (from themes/) or raw HTML dir. | |
| 225 | + output_dir: Where to write the output site. Defaults to output/{theme-name}. | |
| 226 | + cms_name: Whitelabel CMS name for Decap admin UI. | |
| 227 | + cms_logo: Whitelabel logo URL for Decap admin UI. | |
| 228 | + cms_color: Whitelabel top-bar color for Decap admin UI. | |
| 229 | + | |
| 230 | + Returns: | |
| 231 | + Path to the generated site, or error message. | |
| 232 | + """ | |
| 233 | + logging.info(f"Starting pipeline for {input_path} ...") | |
| 234 | + | |
| 235 | + branding = {'cms_name': cms_name, 'cms_logo': cms_logo, 'cms_color': cms_color} | |
| 236 | + info = find_hugo_theme(input_path) | |
| 237 | + | |
| 238 | + if info: | |
| 239 | + return _assemble_hugo_site(info, output_dir, branding) | |
| 240 | + | |
| 241 | + # Next.js path (check before raw HTML since Next.js projects may contain .html files) | |
| 242 | + nextjs_info = find_nextjs_app(input_path) | |
| 243 | + if nextjs_info: | |
| 244 | + return _convert_nextjs(input_path, nextjs_info, output_dir, branding) | |
| 245 | + | |
| 246 | + # Raw HTML path | |
| 247 | + html_files = find_raw_html_files(input_path) | |
| 248 | + if not html_files: | |
| 249 | + raise ValueError(f"No Hugo theme, Next.js app, or HTML files found in {input_path}") | |
| 250 | + return _convert_raw_html(input_path, html_files, |
| --- a/hugoifier/utils/complete.py | |
| +++ b/hugoifier/utils/complete.py | |
| @@ -0,0 +1,250 @@ | |
| --- a/hugoifier/utils/complete.py | |
| +++ b/hugoifier/utils/complete.py | |
| @@ -0,0 +1,250 @@ | |
| 1 | """ |
| 2 | Full end-to-end pipeline: detect → copy → configure → decap. |
| 3 | |
| 4 | For already-Hugo themes: assembles a clean, standalone site. |
| 5 | For raw HTML themes: calls hugoify first, then assembles. |
| 6 | """ |
| 7 | |
| 8 | import logging |
| 9 | import os |
| 10 | import shutil |
| 11 | from pathlib import Path |
| 12 | |
| 13 | from .decapify import decapify |
| 14 | from .hugoify import hugoify_html |
| 15 | from .theme_finder imraw_html_files |
| 16 | from .theme_patcher import patch_config, patch_theme |
| 17 | |
| 18 | |
| 19 | def complete( |
| 20 | input_path: str, |
| 21 | output_dir: str = None, |
| 22 | cms_name: str = None, |
| 23 | cms_logo: str = None, |
| 24 | cms_color: str = None, |
| 25 | ) -> str: |
| 26 | """ |
| 27 | Run the full pipeline for a theme. |
| 28 | |
| 29 | Args: |
| 30 | input_path: Path to a theme directory (from themes/) or raw HTML dir. |
| 31 | output_dir: Where to write the output site. Defaults to output/{theme-name}. |
| 32 | cms_name: Whitelabel CMS name for Decap admin UI. |
| 33 | cms_logo: Whitelabel logo URL for Decap admin UI. |
| 34 | cms_color: Whitelabel top-bar color for Decap admin UI. |
| 35 | |
| 36 | Returns: |
| 37 | Path to the generated site, or error message. |
| 38 | """ |
| 39 | logging.info(f"Starting pipeline for {input_path} ...") |
| 40 | |
| 41 | branding = {'cms_name': cms_name, 'cms_logo': cms_logo, 'cms_color': cms_color} |
| 42 | info = find_hugo_theme(input_path) |
| 43 | |
| 44 | if info: |
| 45 | return _assemble_hugo_site(info, output_dir, branding) |
| 46 | t_raw_html(input_p# Raw HTML path |
| 47 | html_files = find_raw_ if not html_files: |
| 48 | or HTML files fh) |
| 49 | if nextjs_infraw_html(input_path, html_files, output_dir, branding) |
| 50 | |
| 51 | |
| 52 | # --------------------------------------------------------------------------- |
| 53 | # Hugo theme path |
| 54 | # --------------------------------------------------------------------------- |
| 55 | |
| 56 | def _assemble_hugo_site(info: dict, output_dir: str = None, branding: dict = None) -> str: |
| 57 | theme_dir = info['theme_dir'] |
| 58 | example_site = info['example_site'] |
| 59 | theme_name = info['theme_name'] |
| 60 | |
| 61 | if output_dir is None: |
| 62 | output_dir = str(Path(__file__).parents[2] / 'output' / theme_name) |
| 63 | |
| 64 | logging.info(f"Building site at {output_dir} ...") |
| 65 | os.makedirs(output_dir, exist_ok=True) |
| 66 | |
| 67 | # 1. Copy theme files → themes/{theme_name}/ |
| 68 | dest_theme = os.path.join(output_dir, 'themes', theme_name) |
| 69 | _copy_dir(theme_dir, dest_theme, exclude={'exampleSite', '__MACOSX', '.DS_Store'}) |
| 70 | logging.info(f"Copied theme to {dest_theme}") |
| 71 | patch_theme(dest_themy-Hugo themes: assembles a clean, standalone site. |
| 72 | For raw HTML themes: calls hugoify first, then assembles. |
| 73 | """ |
| 74 | |
| 75 | import logging |
| 76 | import os |
| 77 | import shutil |
| 78 | from pathlib import Path |
| 79 | |
| 80 | from .decapify import decapify |
| 81 | from .hugoify import hugoify_html, hugoify_nextjs |
| 82 | from .theme_finder import find_hugo_theme, find_nextjs_app, find_raw_html_files |
| 83 | from .theme_patcher import patch_config, patch_theme |
| 84 | |
| 85 | |
| 86 | def complete( |
| 87 | input_path: str, |
| 88 | output_dir: str = None, |
| 89 | cms_name: str = None, |
| 90 | cms_logo: str = None, |
| 91 | cms_color: str = None, |
| 92 | ) -> str: |
| 93 | """ |
| 94 | Run the full pipeline for a theme. |
| 95 | |
| 96 | Args: |
| 97 | input_path: Path to a theme directory (from themes/) or raw HTML dir. |
| 98 | output_dir: Where to write the output site. Defaults to output/{theme-name}. |
| 99 | cms_name: Whitelabel CMS name for Decap admin UI. |
| 100 | cms_logo: Whitelabel logo URL for Decap admin UI. |
| 101 | cms_color: Whitelabel top-bar color for Decap admin UI. |
| 102 | |
| 103 | Returns: |
| 104 | Path to the generated site, or error message. |
| 105 | """ |
| 106 | logging.info(f"Starting pipeline for {input_path} ...") |
| 107 | |
| 108 | branding = {'cms_name': cms_name, 'cms_logo': cms_logo, 'cms_color': cms_color} |
| 109 | info = find_hugo_theme(input_path) |
| 110 | |
| 111 | if info: |
| 112 | return _assemble_hugo_site(info, output_dir, branding) |
| 113 | |
| 114 | # Next.js path (check before raw HTML since Next.js projects may contain .html files) |
| 115 | nextjs_info = find_nextjs_app(input_path) |
| 116 | if nextjs_info: |
| 117 | return _convert_nextjs(input_path, nextjs_info, output_dir, branding) |
| 118 | |
| 119 | # Raw HTML path |
| 120 | html_files = find_raw_html_files(input_path) |
| 121 | if not html_files: |
| 122 | raise ValueError(f"No Hugo theme, Next.js app, or HTML files found in {input_path}") |
| 123 | return _convert_raw_html(input_path, html_files, output_dir, branding) |
| 124 | |
| 125 | |
| 126 | # --------------------------------------------------------------------------- |
| 127 | # Hugo theme path |
| 128 | # --------------------------------------------------------------------------- |
| 129 | |
| 130 | def _assemble_hugo_site(info: dict, output_dir: str = None, branding: dict = None) -> str: |
| 131 | theme_dir = info['theme_dir'] |
| 132 | example_site = info['example_site'] |
| 133 | theme_name = info['theme_name'] |
| 134 | |
| 135 | if output_dir is None: |
| 136 | output_dir = str(Path(__file__).parents[2] / 'output' / theme_name) |
| 137 | |
| 138 | logging.info(f"Building site at {output_dir} ...") |
| 139 | os.makedirs(output_dir, exist_ok=True) |
| 140 | |
| 141 | # 1. Copy theme files → themes/{theme_name}/ |
| 142 | dest_theme = os.path.join(output_dir, 'themes', theme_name) |
| 143 | _copy_dir(theme_dir, dest_theme, exclude={'exampleSite', '__MACOSX', '.DS_Store'}) |
| 144 | logging.info(f"Copied theme to {dest_theme}") |
| 145 | patch_theme(dest_theme) |
| 146 | |
| 147 | # 2. Copy exampleSite content/static/data → site root |
| 148 | if example_site: |
| 149 | for subdir in ('content', 'data', 'i18n'): |
| 150 | src = os.path.join(example_site, subdir) |
| 151 | if os.path.isdir(src): |
| 152 | _copy_dir(src, os.path.join(output_dir, subdir)) |
| 153 | logging.info(f"Copied {subdir}/ from exampleSite") |
| 154 | |
| 155 | # Static: merge exampleSite/static into output/static |
| 156 | src_static = os.path.join(example_site, 'static') |
| 157 | if os.path.isdir(src_static): |
| 158 | _copy_dir(src_static, os.path.join(output_dir, 'static')) |
| 159 | logging.info("Copied static/ from exampleSite") |
| 160 | |
| 161 | # Write hugo.toml from exampleSite config |
| 162 | config_toml = _find_config(example_site) |
| 163 | if config_toml: |
| 164 | _write_hugo_toml(config_toml, output_dir, theme_name) |
| 165 | else: |
| 166 | _write_minimal_hugo_toml(output_dir, theme_name) |
| 167 | else: |
| 168 | _write_minimal_hugo_toml(output_dir, theme_name) |
| 169 | # Create minimal content/_index.md |
| 170 | content_dir = os.path.join(output_dir, 'content') |
| 171 | os.makedirs(content_dir, exist_ok=True) |
| 172 | index_md = os.path.join(content_dir, '_index.md') |
| 173 | if not os.path.exists(index_md): |
| 174 | with open(index_md, 'w') as f: |
| 175 | f.write('---\ntitle: Home\n---\n') |
| 176 | |
| 177 | # 3. Generate Decap CMS config |
| 178 | b = branding or {} |
| 179 | decapify( |
| 180 | output_dir, |
| 181 | cms_name=b.get('cms_name'), cms_logo=b.get('cms_logo'), cms_color=b.get('cms_color'), |
| 182 | ) |
| 183 | |
| 184 | logging.info(f"Done. Site ready at: {output_dir}") |
| 185 | logging.info(f"Run: cd {output_dir} && hugo serve") |
| 186 | return output_dir |
| 187 | |
| 188 | |
| 189 | # --------------------------------------------------------------------------- |
| 190 | # Next.js path |
| 191 | # --------------------------------------------------------------------------- |
| 192 | |
| 193 | def _convert_nextjs( |
| 194 | input_path: str, nextjs_info: dict, output_dir: str = None, branding: dict = None |
| 195 | ) -> st""" |
| 196 | Full end-to-end pipeline: detect → copy → configure → decap. |
| 197 | |
| 198 | For already-Hugo themes: assembles a clean, standalone site. |
| 199 | For raw HTML themes: calls hugoify first, then assembles. |
| 200 | """ |
| 201 | |
| 202 | import logging |
| 203 | import os |
| 204 | import shutil |
| 205 | from pathlib import Path |
| 206 | |
| 207 | from .decapify import decapify |
| 208 | from .hugoify import hugoify_html, hugoify_nextjs |
| 209 | from .theme_finder import find_hugo_theme, find_nextjs_app, find_raw_html_files |
| 210 | from .theme_patcher import patch_config, patch_theme |
| 211 | |
| 212 | |
| 213 | def complete( |
| 214 | input_path: str, |
| 215 | output_dir: str = None, |
| 216 | cms_name: str = None, |
| 217 | cms_logo: str = None, |
| 218 | cms_color: str = None, |
| 219 | ) -> str: |
| 220 | """ |
| 221 | Run the full pipeline for a theme. |
| 222 | |
| 223 | Args: |
| 224 | input_path: Path to a theme directory (from themes/) or raw HTML dir. |
| 225 | output_dir: Where to write the output site. Defaults to output/{theme-name}. |
| 226 | cms_name: Whitelabel CMS name for Decap admin UI. |
| 227 | cms_logo: Whitelabel logo URL for Decap admin UI. |
| 228 | cms_color: Whitelabel top-bar color for Decap admin UI. |
| 229 | |
| 230 | Returns: |
| 231 | Path to the generated site, or error message. |
| 232 | """ |
| 233 | logging.info(f"Starting pipeline for {input_path} ...") |
| 234 | |
| 235 | branding = {'cms_name': cms_name, 'cms_logo': cms_logo, 'cms_color': cms_color} |
| 236 | info = find_hugo_theme(input_path) |
| 237 | |
| 238 | if info: |
| 239 | return _assemble_hugo_site(info, output_dir, branding) |
| 240 | |
| 241 | # Next.js path (check before raw HTML since Next.js projects may contain .html files) |
| 242 | nextjs_info = find_nextjs_app(input_path) |
| 243 | if nextjs_info: |
| 244 | return _convert_nextjs(input_path, nextjs_info, output_dir, branding) |
| 245 | |
| 246 | # Raw HTML path |
| 247 | html_files = find_raw_html_files(input_path) |
| 248 | if not html_files: |
| 249 | raise ValueError(f"No Hugo theme, Next.js app, or HTML files found in {input_path}") |
| 250 | return _convert_raw_html(input_path, html_files, |
+271
| --- a/hugoifier/utils/decapify.py | ||
| +++ b/hugoifier/utils/decapify.py | ||
| @@ -0,0 +1,271 @@ | ||
| 1 | +""" | |
| 2 | +Generates Decap CMS integration for a Hugo site. | |
| 3 | + | |
| 4 | +Writes: | |
| 5 | + static/admin/index.html — Decap CMS admin panel | |
| 6 | + static/admin/config.yml — CMS config mapped to actual content structure | |
| 7 | +""" | |
| 8 | + | |
| 9 | +import logging | |
| 10 | +import os | |
| 11 | +import re | |
| 12 | + | |
| 13 | +import yaml | |
| 14 | + | |
| 15 | +DECAP_CDN = "https://unpkg.com/decap-cms@^3.0.0/dist/decap-cms.js" | |
| 16 | + | |
| 17 | +# Whitelabel defaults — override via decapify() kwargs or env vars | |
| 18 | +DEFAULT_CMS_NAME = os.getenv('CMS_NAME', 'Content Manager') | |
| 19 | +DEFAULT_CMS_LOGO = os.getenv('CMS_LOGO_URL', '') # URL or empty | |
| 20 | +DEFAULT_CMS_COLOR = os.getenv('CMS_COLOR', '#2e3748') # top-bar background | |
| 21 | + | |
| 22 | + | |
| 23 | +def decapify( | |
| 24 | + site_dir: str, | |
| 25 | + cms_name: str = None, | |
| 26 | + cms_logo: str = None, | |
| 27 | +) -> str: | |
| 28 | + """r = None, | |
| 29 | +) -> str: | |
| 30 | + """ | |
| 31 | + Add Decap CMS to a Hugo site directory. | |
| 32 | + | |
| 33 | + Args: | |
| 34 | + site_dir: Root of the assembled Hugo site (has hugo.toml, content/, thWhitelabel name shown in the admin UI (default: 'Content Manager'). | |
| 35 | + cms_logo: er'). | |
| 36 | + cms_logo: URL to a logo image for the admin UI (optnal). | |
| 37 | + cms_color: | |
| 38 | + Returns: | |
| 39 | + Status message. | |
| 40 | + """ | |
| 41 | + logging.info(f"Adding Decap CMS to {site_dir} ...") | |
| 42 | + | |
| 43 | + admin_dir = os.path.join(site_dir, 'static', 'admin') | |
| 44 | + os.makedirs(admin_dir, exist_ok=True) | |
| 45 | + | |
| 46 | + branding = { | |
| 47 | + 'name': cms_name or DEFAULT_CMS_NAME, | |
| 48 | + 'logo': cms_logo or DEFAULT_CMS_LOGO, | |
| 49 | + 'color': cms_color or DEFAULT_CMS_COLOR, | |
| 50 | + } | |
| 51 | + | |
| 52 | + _write_admin_index(admin_dir, branding) | |
| 53 | + _write_decap_config(site_dir, admin_dir _create_media_dir(site_dir) | |
| 54 | + | |
| 55 | + logging.info("Decap CMS integration complete.") | |
| 56 | + return "Decap CMS integration complete" | |
| 57 | + | |
| 58 | + | |
| 59 | +# --------------------------------------------------------------------------- | |
| 60 | +# Admin index.html | |
| 61 | +# --------------------------------------------------------------------------- | |
| 62 | + | |
| 63 | +def _sanitize_color(color: str) -> str: | |
| 64 | + """Allow only valid CSS hex colors (#rgb or #rrggbb) to prevent style injection.""" | |
| 65 | + if re.fullmatch(r'#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?', color): | |
| 66 | + return color | |
| 67 | + return '#2e3748' # fall back to default | |
| 68 | + | |
| 69 | + | |
| 70 | +def _write_admin_index(admin_dir: str, branding: dict): | |
| 71 | + import html as html_mod | |
| 72 | + name = html_mod.escape(branding['name']) | |
| 73 | + logo_html = '' | |
| 74 | + if branding['logo']: | |
| 75 | + logo_url = html_mod.escape(branding['logo']) | |
| 76 | + logo_html = f'\n <img src="{logo_url}" alt="{name}" style="max-height:40px;margin:8px 0;">' | |
| 77 | + | |
| 78 | + color_css = '' | |
| 79 | + if branding['color']: | |
| 80 | + safe_color = _sanitize_color(branding['color']) | |
| 81 | + color_css = f""" | |
| 82 | + <style> | |
| 83 | + [class^="AppHeader"] {{ background-color: {safe_color} !important; }} | |
| 84 | + </style>""" | |
| 85 | + | |
| 86 | + html = f"""<!doctype html> | |
| 87 | +<html> | |
| 88 | +<head> | |
| 89 | + <meta charset="utf-8" /> | |
| 90 | + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 91 | + <meta name="robots" content="noindex" /> | |
| 92 | + <title>{name}</title>{color_css} | |
| 93 | +</head> | |
| 94 | +<body>{logo_html} | |
| 95 | + <script src="{DECAP_CDN}"></script> | |
| 96 | +</body> | |
| 97 | +</html> | |
| 98 | +""" | |
| 99 | + with open(os.path.join(admin_dir, 'index.html'), 'w') as f: | |
| 100 | + f.write(html) | |
| 101 | + | |
| 102 | + | |
| 103 | +# --------------------------------------------------------------------------- | |
| 104 | +# config.yml | |
| 105 | +# --------------------------------------------------------------------------- | |
| 106 | +edia_dir(site_dir) | |
| 107 | + | |
| 108 | + logging.info("Decap CMS integration complete.") | |
| 109 | + return gbb) to prevent style injectio{ | |
| 110 | + ---------------------gateway', | |
| 111 | + 'branch': 'main', | |
| 112 | +('client_id', clienullmatch(r'#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?', color): | |
| 113 | + return color | |
| 114 | + return '#2e3748' # fall back to default | |
| 115 | + | |
| 116 | + | |
| 117 | +def _write_admin_index(admin_dir: str, branding: dict): | |
| 118 | + import html as html_mod | |
| 119 | + name = html_mod.escape(branding['name']) | |
| 120 | + logo_html = '' | |
| 121 | + if branding['logo']: | |
| 122 | + logo_url = html_mod.escape(branding['logo']) | |
| 123 | + logo_html = f'\n <img src="{logo_url}" alt="{name}" style="max-height:40px;margin:8px 0;">' | |
| 124 | + | |
| 125 | + color_css = '' | |
| 126 | + if branding['color']: | |
| 127 | + safe_color = _sanitize_color(branding['color']) | |
| 128 | + color_css = f""" | |
| 129 | + <style> | |
| 130 | + [class^="AppHeader"] {{ background-color: {safe_color} !important; }} | |
| 131 | + </style>""" | |
| 132 | + | |
| 133 | + html = f"""<!doctype html> | |
| 134 | +<html> | |
| 135 | +<head> | |
| 136 | + <meta charset="utf-8" /> | |
| 137 | + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 138 | + <meta name="robots" content="noindex" /> | |
| 139 | + <title>{name}</title>{color_css} | |
| 140 | +</head> | |
| 141 | +<body>{logo_html} | |
| 142 | + <script src="{DECAP_CDN}"></script> | |
| 143 | +</body> | |
| 144 | +</html> | |
| 145 | +""" | |
| 146 | + with open(os.path.join(admin_dir, 'index.html'), 'w') as f: | |
| 147 | + f.write(html) | |
| 148 | + | |
| 149 | + | |
| 150 | +# --------------------------------------------------------------------------- | |
| 151 | +# config.yml | |
| 152 | +# --------------------------------------------------------------------------- | |
| 153 | + | |
| 154 | +def _write_decap_config(site_dir: str, admin_dir: str, github_repo: str = None): | |
| 155 | + content_dir = os.path.join(site_dir, 'content') | |
| 156 | + collections = _build_collections(content_dir) | |
| 157 | + | |
| 158 | + backend = { | |
| 159 | + 'name': 'github', | |
| 160 | + 'branch': 'main', | |
| 161 | + } | |
| 162 | + if github_repo: | |
| 163 | + backend['repo'] = github_repo | |
| 164 | + backend['base_url'] = '' # placeholder — set to deployed site URL | |
| 165 | + backend['auth_endpoint'] = '/api/auth' | |
| 166 | + | |
| 167 | + config = { | |
| 168 | + 'backend': backend, | |
| 169 | + 'media_folder': 'static/images/uploads', | |
| 170 | + 'public_folder': '/images/uploads', | |
| 171 | + 'collections': collections, | |
| 172 | + } | |
| 173 | + | |
| 174 | + config_path = os.path.join(admin_dir, 'config.yml') | |
| 175 | + with open(config_path, 'w') as f: | |
| 176 | + yaml.dump(config, f, default_flow_style=False, allow_unicode=True, sort_keys=False) | |
| 177 | + | |
| 178 | + logging.info(f"Wrote Decap CMS config to {config_path}") | |
| 179 | + | |
| 180 | + | |
| 181 | +def _collect_md_files(dirpath: str) -> list: | |
| 182 | + """Recursively collect all .md files under dirpath (excluding _index.md).""" | |
| 183 | + found = [] | |
| 184 | + for root, dirs, files in os.walk(dirpath): | |
| 185 | + for f in files: | |
| 186 | + if f.endswith('.md') and f != '_index.md': | |
| 187 | + found.append(os.path.join(root, f)) | |
| 188 | + return found | |
| 189 | + | |
| 190 | + | |
| 191 | +def _build_collections(content_dir: str) -> list: | |
| 192 | + """ | |
| 193 | + Inspect content/ to build Decap CMS collections. | |
| 194 | + - Subdirs with any .md files (at any depth) → folder collection (e.g. blog) | |
| 195 | + - Subdirs with only a top-level _index.md → file collection (e.g. about, contact) | |
| 196 | + """ | |
| 197 | + if not os.path.isdir(content_dir): | |
| 198 | + return [_default_pages_collection()] | |
| 199 | + | |
| 200 | + collections = [] | |
| 201 | + | |
| 202 | + entries = sorted(os.listdir(content_dir)) | |
| 203 | + for entry in entries: | |
| 204 | + subdir = os.path.join(content_dir, entry) | |
| 205 | + if not os.path.isdir(subdir): | |
| 206 | + continue | |
| 207 | + | |
| 208 | + # Collect all .md files at any depth (excluding _index.md) | |
| 209 | + non_index = _collect_md_files(subdir) | |
| 210 | + has_index = os.path.exists(os.path.join(subdir, '_index.md')) | |
| 211 | + | |
| 212 | + if non_index: | |
| 213 | + # Folder collection (blog, posts, etc.) — use shallowest sample for field inference | |
| 214 | + rel_files = [os.path.relpath(f, subdir) for f in non_index] | |
| 215 | + fields = _infer_fields_for_folder(subdir, rel_files) | |
| 216 | + collections.append({ | |
| 217 | + 'name': entry, | |
| 218 | + 'label': entry.replace('-', ' ').title(), | |
| 219 | + 'folder': f'content/{entry}', | |
| 220 | + 'create': True, | |
| 221 | + 'slug': '{{slug}}', | |
| 222 | + 'fields': fields, | |
| 223 | + }) | |
| 224 | + elif has_index: | |
| 225 | + # File collection (single page) | |
| 226 | + fields = _infer_fields_for_file(os.path.join(subdir, '_index.md')) | |
| 227 | + collections.append({ | |
| 228 | + 'name': entry, | |
| 229 | + 'label': entry.replace('-', ' ').title(), | |
| 230 | + 'files': [{ | |
| 231 | + 'name': entry, | |
| 232 | + 'label': entry.replace('-', ' ').title(), | |
| 233 | + 'file': f'content/{entry}/_index.md', | |
| 234 | + 'fields': fields, | |
| 235 | + }], | |
| 236 | + }) | |
| 237 | + | |
| 238 | + if not collections: | |
| 239 | + collections.append(_default_pages_collection()) | |
| 240 | + | |
| 241 | + return collections | |
| 242 | + | |
| 243 | + | |
| 244 | +def _infer_fields_for_folder(subdir: str, md_files: list) -> list: | |
| 245 | + """Read a sample .md file and extract frontmatter keys as fields.""" | |
| 246 | + # md_files may be relative paths (from _collect_md_files); resolve to absolute | |
| 247 | + first = md_files[0] | |
| 248 | + sample = first if os.path.isabs(first) else os.path.join(subdir, first) | |
| 249 | + frontmatter = _parse_frontmatter(sample) | |
| 250 | + | |
| 251 | + fields = [] | |
| 252 | + field_map = { | |
| 253 | + 'title': {'label': 'Title', 'name': 'title', 'widget': 'string'}, | |
| 254 | + 'date': {'label': 'Date', 'name': 'date', 'widget': 'datetime'}, | |
| 255 | + 'description': {'label': 'Description', 'name': 'description', 'widget': 'text'}, | |
| 256 | + 'image': {'label': 'Image', 'name': 'image', 'widget': 'image', 'required': False}, | |
| 257 | + 'categories': {'label': 'Categories', 'name': 'categories', 'widget': 'list', | |
| 258 | + 'required': False}, | |
| 259 | + 'tags': {'label': 'Tags', 'name': 'tags', 'widget': 'list', 'required': False}, | |
| 260 | + 'draft': {'label': 'Draft', 'name': 'draft', 'widget': 'boolean', 'default': False}, | |
| 261 | + 'author': {'label': 'Author', 'name': 'author', 'widget': 'string', 'required': False}, | |
| 262 | + } | |
| 263 | + | |
| 264 | + # Add known fields in a logical order | |
| 265 | + for key in ['title', 'date', 'description', 'image', 'categories', 'tags', 'author', 'draft']: | |
| 266 | + if key in frontmatter: | |
| 267 | + fields.append(field_map[key]) | |
| 268 | + | |
| 269 | + # Add any remaining frontmatter keys not in our map | |
| 270 | + for key, value in frontmatter.items(): | |
| 271 | + if key not in field_map and key not in ('type |
| --- a/hugoifier/utils/decapify.py | |
| +++ b/hugoifier/utils/decapify.py | |
| @@ -0,0 +1,271 @@ | |
| --- a/hugoifier/utils/decapify.py | |
| +++ b/hugoifier/utils/decapify.py | |
| @@ -0,0 +1,271 @@ | |
| 1 | """ |
| 2 | Generates Decap CMS integration for a Hugo site. |
| 3 | |
| 4 | Writes: |
| 5 | static/admin/index.html — Decap CMS admin panel |
| 6 | static/admin/config.yml — CMS config mapped to actual content structure |
| 7 | """ |
| 8 | |
| 9 | import logging |
| 10 | import os |
| 11 | import re |
| 12 | |
| 13 | import yaml |
| 14 | |
| 15 | DECAP_CDN = "https://unpkg.com/decap-cms@^3.0.0/dist/decap-cms.js" |
| 16 | |
| 17 | # Whitelabel defaults — override via decapify() kwargs or env vars |
| 18 | DEFAULT_CMS_NAME = os.getenv('CMS_NAME', 'Content Manager') |
| 19 | DEFAULT_CMS_LOGO = os.getenv('CMS_LOGO_URL', '') # URL or empty |
| 20 | DEFAULT_CMS_COLOR = os.getenv('CMS_COLOR', '#2e3748') # top-bar background |
| 21 | |
| 22 | |
| 23 | def decapify( |
| 24 | site_dir: str, |
| 25 | cms_name: str = None, |
| 26 | cms_logo: str = None, |
| 27 | ) -> str: |
| 28 | """r = None, |
| 29 | ) -> str: |
| 30 | """ |
| 31 | Add Decap CMS to a Hugo site directory. |
| 32 | |
| 33 | Args: |
| 34 | site_dir: Root of the assembled Hugo site (has hugo.toml, content/, thWhitelabel name shown in the admin UI (default: 'Content Manager'). |
| 35 | cms_logo: er'). |
| 36 | cms_logo: URL to a logo image for the admin UI (optnal). |
| 37 | cms_color: |
| 38 | Returns: |
| 39 | Status message. |
| 40 | """ |
| 41 | logging.info(f"Adding Decap CMS to {site_dir} ...") |
| 42 | |
| 43 | admin_dir = os.path.join(site_dir, 'static', 'admin') |
| 44 | os.makedirs(admin_dir, exist_ok=True) |
| 45 | |
| 46 | branding = { |
| 47 | 'name': cms_name or DEFAULT_CMS_NAME, |
| 48 | 'logo': cms_logo or DEFAULT_CMS_LOGO, |
| 49 | 'color': cms_color or DEFAULT_CMS_COLOR, |
| 50 | } |
| 51 | |
| 52 | _write_admin_index(admin_dir, branding) |
| 53 | _write_decap_config(site_dir, admin_dir _create_media_dir(site_dir) |
| 54 | |
| 55 | logging.info("Decap CMS integration complete.") |
| 56 | return "Decap CMS integration complete" |
| 57 | |
| 58 | |
| 59 | # --------------------------------------------------------------------------- |
| 60 | # Admin index.html |
| 61 | # --------------------------------------------------------------------------- |
| 62 | |
| 63 | def _sanitize_color(color: str) -> str: |
| 64 | """Allow only valid CSS hex colors (#rgb or #rrggbb) to prevent style injection.""" |
| 65 | if re.fullmatch(r'#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?', color): |
| 66 | return color |
| 67 | return '#2e3748' # fall back to default |
| 68 | |
| 69 | |
| 70 | def _write_admin_index(admin_dir: str, branding: dict): |
| 71 | import html as html_mod |
| 72 | name = html_mod.escape(branding['name']) |
| 73 | logo_html = '' |
| 74 | if branding['logo']: |
| 75 | logo_url = html_mod.escape(branding['logo']) |
| 76 | logo_html = f'\n <img src="{logo_url}" alt="{name}" style="max-height:40px;margin:8px 0;">' |
| 77 | |
| 78 | color_css = '' |
| 79 | if branding['color']: |
| 80 | safe_color = _sanitize_color(branding['color']) |
| 81 | color_css = f""" |
| 82 | <style> |
| 83 | [class^="AppHeader"] {{ background-color: {safe_color} !important; }} |
| 84 | </style>""" |
| 85 | |
| 86 | html = f"""<!doctype html> |
| 87 | <html> |
| 88 | <head> |
| 89 | <meta charset="utf-8" /> |
| 90 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 91 | <meta name="robots" content="noindex" /> |
| 92 | <title>{name}</title>{color_css} |
| 93 | </head> |
| 94 | <body>{logo_html} |
| 95 | <script src="{DECAP_CDN}"></script> |
| 96 | </body> |
| 97 | </html> |
| 98 | """ |
| 99 | with open(os.path.join(admin_dir, 'index.html'), 'w') as f: |
| 100 | f.write(html) |
| 101 | |
| 102 | |
| 103 | # --------------------------------------------------------------------------- |
| 104 | # config.yml |
| 105 | # --------------------------------------------------------------------------- |
| 106 | edia_dir(site_dir) |
| 107 | |
| 108 | logging.info("Decap CMS integration complete.") |
| 109 | return gbb) to prevent style injectio{ |
| 110 | ---------------------gateway', |
| 111 | 'branch': 'main', |
| 112 | ('client_id', clienullmatch(r'#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?', color): |
| 113 | return color |
| 114 | return '#2e3748' # fall back to default |
| 115 | |
| 116 | |
| 117 | def _write_admin_index(admin_dir: str, branding: dict): |
| 118 | import html as html_mod |
| 119 | name = html_mod.escape(branding['name']) |
| 120 | logo_html = '' |
| 121 | if branding['logo']: |
| 122 | logo_url = html_mod.escape(branding['logo']) |
| 123 | logo_html = f'\n <img src="{logo_url}" alt="{name}" style="max-height:40px;margin:8px 0;">' |
| 124 | |
| 125 | color_css = '' |
| 126 | if branding['color']: |
| 127 | safe_color = _sanitize_color(branding['color']) |
| 128 | color_css = f""" |
| 129 | <style> |
| 130 | [class^="AppHeader"] {{ background-color: {safe_color} !important; }} |
| 131 | </style>""" |
| 132 | |
| 133 | html = f"""<!doctype html> |
| 134 | <html> |
| 135 | <head> |
| 136 | <meta charset="utf-8" /> |
| 137 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 138 | <meta name="robots" content="noindex" /> |
| 139 | <title>{name}</title>{color_css} |
| 140 | </head> |
| 141 | <body>{logo_html} |
| 142 | <script src="{DECAP_CDN}"></script> |
| 143 | </body> |
| 144 | </html> |
| 145 | """ |
| 146 | with open(os.path.join(admin_dir, 'index.html'), 'w') as f: |
| 147 | f.write(html) |
| 148 | |
| 149 | |
| 150 | # --------------------------------------------------------------------------- |
| 151 | # config.yml |
| 152 | # --------------------------------------------------------------------------- |
| 153 | |
| 154 | def _write_decap_config(site_dir: str, admin_dir: str, github_repo: str = None): |
| 155 | content_dir = os.path.join(site_dir, 'content') |
| 156 | collections = _build_collections(content_dir) |
| 157 | |
| 158 | backend = { |
| 159 | 'name': 'github', |
| 160 | 'branch': 'main', |
| 161 | } |
| 162 | if github_repo: |
| 163 | backend['repo'] = github_repo |
| 164 | backend['base_url'] = '' # placeholder — set to deployed site URL |
| 165 | backend['auth_endpoint'] = '/api/auth' |
| 166 | |
| 167 | config = { |
| 168 | 'backend': backend, |
| 169 | 'media_folder': 'static/images/uploads', |
| 170 | 'public_folder': '/images/uploads', |
| 171 | 'collections': collections, |
| 172 | } |
| 173 | |
| 174 | config_path = os.path.join(admin_dir, 'config.yml') |
| 175 | with open(config_path, 'w') as f: |
| 176 | yaml.dump(config, f, default_flow_style=False, allow_unicode=True, sort_keys=False) |
| 177 | |
| 178 | logging.info(f"Wrote Decap CMS config to {config_path}") |
| 179 | |
| 180 | |
| 181 | def _collect_md_files(dirpath: str) -> list: |
| 182 | """Recursively collect all .md files under dirpath (excluding _index.md).""" |
| 183 | found = [] |
| 184 | for root, dirs, files in os.walk(dirpath): |
| 185 | for f in files: |
| 186 | if f.endswith('.md') and f != '_index.md': |
| 187 | found.append(os.path.join(root, f)) |
| 188 | return found |
| 189 | |
| 190 | |
| 191 | def _build_collections(content_dir: str) -> list: |
| 192 | """ |
| 193 | Inspect content/ to build Decap CMS collections. |
| 194 | - Subdirs with any .md files (at any depth) → folder collection (e.g. blog) |
| 195 | - Subdirs with only a top-level _index.md → file collection (e.g. about, contact) |
| 196 | """ |
| 197 | if not os.path.isdir(content_dir): |
| 198 | return [_default_pages_collection()] |
| 199 | |
| 200 | collections = [] |
| 201 | |
| 202 | entries = sorted(os.listdir(content_dir)) |
| 203 | for entry in entries: |
| 204 | subdir = os.path.join(content_dir, entry) |
| 205 | if not os.path.isdir(subdir): |
| 206 | continue |
| 207 | |
| 208 | # Collect all .md files at any depth (excluding _index.md) |
| 209 | non_index = _collect_md_files(subdir) |
| 210 | has_index = os.path.exists(os.path.join(subdir, '_index.md')) |
| 211 | |
| 212 | if non_index: |
| 213 | # Folder collection (blog, posts, etc.) — use shallowest sample for field inference |
| 214 | rel_files = [os.path.relpath(f, subdir) for f in non_index] |
| 215 | fields = _infer_fields_for_folder(subdir, rel_files) |
| 216 | collections.append({ |
| 217 | 'name': entry, |
| 218 | 'label': entry.replace('-', ' ').title(), |
| 219 | 'folder': f'content/{entry}', |
| 220 | 'create': True, |
| 221 | 'slug': '{{slug}}', |
| 222 | 'fields': fields, |
| 223 | }) |
| 224 | elif has_index: |
| 225 | # File collection (single page) |
| 226 | fields = _infer_fields_for_file(os.path.join(subdir, '_index.md')) |
| 227 | collections.append({ |
| 228 | 'name': entry, |
| 229 | 'label': entry.replace('-', ' ').title(), |
| 230 | 'files': [{ |
| 231 | 'name': entry, |
| 232 | 'label': entry.replace('-', ' ').title(), |
| 233 | 'file': f'content/{entry}/_index.md', |
| 234 | 'fields': fields, |
| 235 | }], |
| 236 | }) |
| 237 | |
| 238 | if not collections: |
| 239 | collections.append(_default_pages_collection()) |
| 240 | |
| 241 | return collections |
| 242 | |
| 243 | |
| 244 | def _infer_fields_for_folder(subdir: str, md_files: list) -> list: |
| 245 | """Read a sample .md file and extract frontmatter keys as fields.""" |
| 246 | # md_files may be relative paths (from _collect_md_files); resolve to absolute |
| 247 | first = md_files[0] |
| 248 | sample = first if os.path.isabs(first) else os.path.join(subdir, first) |
| 249 | frontmatter = _parse_frontmatter(sample) |
| 250 | |
| 251 | fields = [] |
| 252 | field_map = { |
| 253 | 'title': {'label': 'Title', 'name': 'title', 'widget': 'string'}, |
| 254 | 'date': {'label': 'Date', 'name': 'date', 'widget': 'datetime'}, |
| 255 | 'description': {'label': 'Description', 'name': 'description', 'widget': 'text'}, |
| 256 | 'image': {'label': 'Image', 'name': 'image', 'widget': 'image', 'required': False}, |
| 257 | 'categories': {'label': 'Categories', 'name': 'categories', 'widget': 'list', |
| 258 | 'required': False}, |
| 259 | 'tags': {'label': 'Tags', 'name': 'tags', 'widget': 'list', 'required': False}, |
| 260 | 'draft': {'label': 'Draft', 'name': 'draft', 'widget': 'boolean', 'default': False}, |
| 261 | 'author': {'label': 'Author', 'name': 'author', 'widget': 'string', 'required': False}, |
| 262 | } |
| 263 | |
| 264 | # Add known fields in a logical order |
| 265 | for key in ['title', 'date', 'description', 'image', 'categories', 'tags', 'author', 'draft']: |
| 266 | if key in frontmatter: |
| 267 | fields.append(field_map[key]) |
| 268 | |
| 269 | # Add any remaining frontmatter keys not in our map |
| 270 | for key, value in frontmatter.items(): |
| 271 | if key not in field_map and key not in ('type |
| --- a/hugoifier/utils/deploy.py | ||
| +++ b/hugoifier/utils/deploy.py | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +""" | |
| 2 | +Handles deployment of a Hugo site, ensuring prerequisites are met | |
| 3 | +and executing the deployment process (optionally via Cloudflare). | |
| 4 | +""" | |
| 5 | + | |
| 6 | +import logging | |
| 7 | + | |
| 8 | + | |
| 9 | +# Function to handle deployment tasks | |
| 10 | +def deploy(path, zone): | |
| 11 | + logging.info(f"Starting deployment for {path} to zone {zone}...") | |
| 12 | + try: | |
| 13 | + # Check prerequisites | |
| 14 | + logging.info("Checking prerequisites...") | |
| 15 | + # Example check for necessary files or configurations | |
| 16 | + # if not os.path.exists(os.path.join(path, 'config.toml')): | |
| 17 | + # raise FileNotFoundError("Missing config.toml") | |
| 18 | + | |
| 19 | + # Deploy site using Cloudflare functions | |
| 20 | + logging.info("Deploying site...") | |
| 21 | + # Example API call to deploy the site | |
| 22 | + # cloudflare_api.deploy_site(path, zone) | |
| 23 | + | |
| 24 | + logging.info("Deployment complete.") | |
| 25 | + return "Deployment complete" | |
| 26 | + except Exception as e: | |
| 27 | + logging.error(f"Error during deployment: {e}") | |
| 28 | + return "Deployment failed" |
| --- a/hugoifier/utils/deploy.py | |
| +++ b/hugoifier/utils/deploy.py | |
| @@ -0,0 +1,28 @@ | |
| --- a/hugoifier/utils/deploy.py | |
| +++ b/hugoifier/utils/deploy.py | |
| @@ -0,0 +1,28 @@ | |
| 1 | """ |
| 2 | Handles deployment of a Hugo site, ensuring prerequisites are met |
| 3 | and executing the deployment process (optionally via Cloudflare). |
| 4 | """ |
| 5 | |
| 6 | import logging |
| 7 | |
| 8 | |
| 9 | # Function to handle deployment tasks |
| 10 | def deploy(path, zone): |
| 11 | logging.info(f"Starting deployment for {path} to zone {zone}...") |
| 12 | try: |
| 13 | # Check prerequisites |
| 14 | logging.info("Checking prerequisites...") |
| 15 | # Example check for necessary files or configurations |
| 16 | # if not os.path.exists(os.path.join(path, 'config.toml')): |
| 17 | # raise FileNotFoundError("Missing config.toml") |
| 18 | |
| 19 | # Deploy site using Cloudflare functions |
| 20 | logging.info("Deploying site...") |
| 21 | # Example API call to deploy the site |
| 22 | # cloudflare_api.deploy_site(path, zone) |
| 23 | |
| 24 | logging.info("Deployment complete.") |
| 25 | return "Deployment complete" |
| 26 | except Exception as e: |
| 27 | logging.error(f"Error during deployment: {e}") |
| 28 | return "Deployment failed" |
| --- a/hugoifier/utils/generate_decap_config.py | ||
| +++ b/hugoifier/utils/generate_decap_config.py | ||
| @@ -0,0 +1,10 @@ | ||
| 1 | +""" | |
| 2 | +generate_decap_config — thin wrapper kept for backwards compatibility. | |
| 3 | +The real implementation lives in decapify.py. | |
| 4 | +""" | |
| 5 | + | |
| 6 | +from .decapify import decapify | |
| 7 | + | |
| 8 | + | |
| 9 | +def generate_decap_config(theme_path: str) -> str: | |
| 10 | + return decapify(theme_path) |
| --- a/hugoifier/utils/generate_decap_config.py | |
| +++ b/hugoifier/utils/generate_decap_config.py | |
| @@ -0,0 +1,10 @@ | |
| --- a/hugoifier/utils/generate_decap_config.py | |
| +++ b/hugoifier/utils/generate_decap_config.py | |
| @@ -0,0 +1,10 @@ | |
| 1 | """ |
| 2 | generate_decap_config — thin wrapper kept for backwards compatibility. |
| 3 | The real implementation lives in decapify.py. |
| 4 | """ |
| 5 | |
| 6 | from .decapify import decapify |
| 7 | |
| 8 | |
| 9 | def generate_decap_config(theme_path: str) -> str: |
| 10 | return decapify(theme_path) |
+105
| --- a/hugoifier/utils/hugoify.py | ||
| +++ b/hugoifier/utils/hugoify.py | ||
| @@ -0,0 +1,105 @@ | ||
| 1 | +Returns dict mapping relative la, e.g.: | |
| 2 | + if rel_path.endswith('.css'): | |
| 3 | + "<!DOCTYPE html>..."t_nextjs_sources...import logging | |
| 4 | +im"""tml_path, 'r) | |
| 5 | + | |
| 6 | + # Extract <body> content | |
| 7 | + body_match = re.search(r'<body[^>]*>(.*?)</body>', html, re.DOTALL) | |
| 8 | + body_content = body_match.group(1).strip() if body_match else html | |
| 9 | + | |
| 10 | + # Extract body attributes (class, style, etc.) | |
| 11 | + body_attrs_match = re.search(r'<body([^>]*)>', html) | |
| 12 | + | |
| 13 | + r'<title>[^<]*</title>', | |
| 14 | + '<title>{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} | {{ .Site.Title }}{{ end }}</title>', | |
| 15 | + head_content | |
| 16 | + ) | |
| 17 | + baseof = f'''<!DOCTYPE html> | |
| 18 | +<html lang="{{{{ | |
| 19 | +HTML to convert: | |
| 20 | +{html}eCode }}}}{{{{ . }}}}{{{{ else{{{{ end }}}}' | |
| 21 | + | |
| 22 | + layouts = { | |
| 23 | + "_default/baseof.html": baseof, | |
| 24 | + racted {len(layouts)} layout files directly from HTML (no AI)") | |
| 25 | + return same format as hugoify_html() | |
| 26 | + """ | |
| 27 | + Convert a Next.js app to a set of Hugo layout files. | |
| 28 | + | |
| 29 | + If dev_u# Build the source context for tsource_block += f"\n{'='*60}\n// FILE: {rel_path}\n{'='*60}\n{content}\n"rn layouts | |
| 30 | + | |
| 31 | + | |
| 32 | +dNext.js React applicationPD,1:)4OR@pK,37zDWi;the HTML shell, <head>, and blocksise falls back to igationAI-powered TSX source conversion. | |
| 33 | + | |
| 34 | + Args: | |
| 35 | + info: dict from find_nextjs_app() w}} ... {{{{ end }}}} | |
| 36 | +- Additional "partials/{{name}}.html" for each major section component | |
| 37 | + | |
| 38 | +Conversion rules: | |
| 39 | +- JSX `className` → the original <head> struct → Hugo partials via `{{{{ partial "name.html" . }}}}` | |
| 40 | +- `app/layout.tsx` → `eturns dict mapping apping relative la, e.g.: | |
| 41 | + if rel_path.endswith('.css'): | |
| 42 | + "<!DOCTYPE html>..."t."t_nextjs_sources...imReturns - Animation wrappers (FadeIn, motion.div) → plain `<div>` elements preserving clasace hardcoded <title> with Hugo template | |
| 43 | + head_content = re.sub( | |
| 44 | + r'<title>[^<]*</title>', | |
| 45 | + '<title>{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} | {{ .Site.Title }}{{ end }}</title>', | |
| 46 | + head_content | |
| 47 | + ) | |
| 48 | + baseof = f'''<!DOCTYPE html> | |
| 49 | +<html lang="{{{{ with .Site.LanguageCode }}}}{{{{ . }}}}{{{{ else }}}}en{{{{ end }}}}"> | |
| 50 | +<head> | |
| 51 | +{head_content} | |
| 52 | +</head> | |
| 53 | +<body{" " + body_attrs if body_attrs else ""}> | |
| 54 | + {{{{- block "main" . }}}}{{{{- end }}}} | |
| 55 | +</body> | |
| 56 | +</html>''' | |
| 57 | + else: | |
| 58 | + baseof = _fallback_baseof() | |
| 59 | + | |
| 60 | + index_html = f'{{{{ dturns dict mapping relative la, e.g.: | |
| 61 | + if rel_path.endswith('.css'): | |
| 62 | + "<!DOCTYrelative la, e.g.: | |
| 63 | + if rel_path.endswith('.css'): | |
| 64 | + "<!DOCTYPE html>..."t_nextjs_sources...import logging | |
| 65 | +im"""tml_path, 'r', errors='replace') as f: | |
| 66 | + html = f.read() | |
| 67 | + | |
| 68 | + logging.info(f"Read {len(html)} chars from {html_path}") | |
| 69 | + | |
| 70 | + # Extract <body> content | |
| 71 | + body_match = re.search(r'<body[^>]*>(.*?)</body>', html, re.DOTALL) | |
| 72 | + body_content = body_match.group(1).strip() if body_match else html | |
| 73 | + | |
| 74 | + # Extract body attributes (class, style, etc.) | |
| 75 | + body_attrs_match = re.search(r'<body([^>]*)>', html) | |
| 76 | + body_attrs = body_attrs_match.group(1).strip() if body_attrs_match else '' | |
| 77 | + | |
| 78 | + # Build baseof.html preserving the original <head> structure | |
| 79 | + head_match = re.search(r'<head[^>]*>(.*?)</head>', html, re.DOTALL) | |
| 80 | + if head_match: | |
| 81 | + head_content = head_match.gr."""responseturnng relative la, e.g.: | |
| 82 | + ) | |
| 83 | + | |
| 84 | + logging.info(f"Read {len(html)} chars from {html_path}") | |
| 85 | + | |
| 86 | + # Extract <body> content | |
| 87 | + Returns dict mapping relative la, e.g.: | |
| 88 | + if rel_path.endswith('.css'): | |
| 89 | + "<!DOCTYPE html>..."t_nextjs_sources...import logging | |
| 90 | +im"""tml_path, 'r', errors='replace') as f: | |
| 91 | + html = f.read() | |
| 92 | + | |
| 93 | + logging.info(f"Read {len(html)} chars from {html_path}") | |
| 94 | + | |
| 95 | + # Extract <body> content | |
| 96 | + body_match = re.search(r'<body[^>]*>(.*?)</body>', html, re.DOTALL) | |
| 97 | + body_content = body_match.group(1).strip() if body_match else html | |
| 98 | + | |
| 99 | + # Extract body attributes (class, style, etc.) | |
| 100 | + body_attrs_match = re.search(r'<body([^>]*)>', html) | |
| 101 | + body_attrs = body_attrs_match.group(1).strip() if body_attrs_match else '' | |
| 102 | + | |
| 103 | + # Build baseof.html preserving the original <head> structure | |
| 104 | + head_match = re.search(r'<head[^>]*>(.*?)</head>', html, re.DOTALL) | |
| 105 | + if head_mat |
| --- a/hugoifier/utils/hugoify.py | |
| +++ b/hugoifier/utils/hugoify.py | |
| @@ -0,0 +1,105 @@ | |
| --- a/hugoifier/utils/hugoify.py | |
| +++ b/hugoifier/utils/hugoify.py | |
| @@ -0,0 +1,105 @@ | |
| 1 | Returns dict mapping relative la, e.g.: |
| 2 | if rel_path.endswith('.css'): |
| 3 | "<!DOCTYPE html>..."t_nextjs_sources...import logging |
| 4 | im"""tml_path, 'r) |
| 5 | |
| 6 | # Extract <body> content |
| 7 | body_match = re.search(r'<body[^>]*>(.*?)</body>', html, re.DOTALL) |
| 8 | body_content = body_match.group(1).strip() if body_match else html |
| 9 | |
| 10 | # Extract body attributes (class, style, etc.) |
| 11 | body_attrs_match = re.search(r'<body([^>]*)>', html) |
| 12 | |
| 13 | r'<title>[^<]*</title>', |
| 14 | '<title>{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} | {{ .Site.Title }}{{ end }}</title>', |
| 15 | head_content |
| 16 | ) |
| 17 | baseof = f'''<!DOCTYPE html> |
| 18 | <html lang="{{{{ |
| 19 | HTML to convert: |
| 20 | {html}eCode }}}}{{{{ . }}}}{{{{ else{{{{ end }}}}' |
| 21 | |
| 22 | layouts = { |
| 23 | "_default/baseof.html": baseof, |
| 24 | racted {len(layouts)} layout files directly from HTML (no AI)") |
| 25 | return same format as hugoify_html() |
| 26 | """ |
| 27 | Convert a Next.js app to a set of Hugo layout files. |
| 28 | |
| 29 | If dev_u# Build the source context for tsource_block += f"\n{'='*60}\n// FILE: {rel_path}\n{'='*60}\n{content}\n"rn layouts |
| 30 | |
| 31 | |
| 32 | dNext.js React applicationPD,1:)4OR@pK,37zDWi;the HTML shell, <head>, and blocksise falls back to igationAI-powered TSX source conversion. |
| 33 | |
| 34 | Args: |
| 35 | info: dict from find_nextjs_app() w}} ... {{{{ end }}}} |
| 36 | - Additional "partials/{{name}}.html" for each major section component |
| 37 | |
| 38 | Conversion rules: |
| 39 | - JSX `className` → the original <head> struct → Hugo partials via `{{{{ partial "name.html" . }}}}` |
| 40 | - `app/layout.tsx` → `eturns dict mapping apping relative la, e.g.: |
| 41 | if rel_path.endswith('.css'): |
| 42 | "<!DOCTYPE html>..."t."t_nextjs_sources...imReturns - Animation wrappers (FadeIn, motion.div) → plain `<div>` elements preserving clasace hardcoded <title> with Hugo template |
| 43 | head_content = re.sub( |
| 44 | r'<title>[^<]*</title>', |
| 45 | '<title>{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} | {{ .Site.Title }}{{ end }}</title>', |
| 46 | head_content |
| 47 | ) |
| 48 | baseof = f'''<!DOCTYPE html> |
| 49 | <html lang="{{{{ with .Site.LanguageCode }}}}{{{{ . }}}}{{{{ else }}}}en{{{{ end }}}}"> |
| 50 | <head> |
| 51 | {head_content} |
| 52 | </head> |
| 53 | <body{" " + body_attrs if body_attrs else ""}> |
| 54 | {{{{- block "main" . }}}}{{{{- end }}}} |
| 55 | </body> |
| 56 | </html>''' |
| 57 | else: |
| 58 | baseof = _fallback_baseof() |
| 59 | |
| 60 | index_html = f'{{{{ dturns dict mapping relative la, e.g.: |
| 61 | if rel_path.endswith('.css'): |
| 62 | "<!DOCTYrelative la, e.g.: |
| 63 | if rel_path.endswith('.css'): |
| 64 | "<!DOCTYPE html>..."t_nextjs_sources...import logging |
| 65 | im"""tml_path, 'r', errors='replace') as f: |
| 66 | html = f.read() |
| 67 | |
| 68 | logging.info(f"Read {len(html)} chars from {html_path}") |
| 69 | |
| 70 | # Extract <body> content |
| 71 | body_match = re.search(r'<body[^>]*>(.*?)</body>', html, re.DOTALL) |
| 72 | body_content = body_match.group(1).strip() if body_match else html |
| 73 | |
| 74 | # Extract body attributes (class, style, etc.) |
| 75 | body_attrs_match = re.search(r'<body([^>]*)>', html) |
| 76 | body_attrs = body_attrs_match.group(1).strip() if body_attrs_match else '' |
| 77 | |
| 78 | # Build baseof.html preserving the original <head> structure |
| 79 | head_match = re.search(r'<head[^>]*>(.*?)</head>', html, re.DOTALL) |
| 80 | if head_match: |
| 81 | head_content = head_match.gr."""responseturnng relative la, e.g.: |
| 82 | ) |
| 83 | |
| 84 | logging.info(f"Read {len(html)} chars from {html_path}") |
| 85 | |
| 86 | # Extract <body> content |
| 87 | Returns dict mapping relative la, e.g.: |
| 88 | if rel_path.endswith('.css'): |
| 89 | "<!DOCTYPE html>..."t_nextjs_sources...import logging |
| 90 | im"""tml_path, 'r', errors='replace') as f: |
| 91 | html = f.read() |
| 92 | |
| 93 | logging.info(f"Read {len(html)} chars from {html_path}") |
| 94 | |
| 95 | # Extract <body> content |
| 96 | body_match = re.search(r'<body[^>]*>(.*?)</body>', html, re.DOTALL) |
| 97 | body_content = body_match.group(1).strip() if body_match else html |
| 98 | |
| 99 | # Extract body attributes (class, style, etc.) |
| 100 | body_attrs_match = re.search(r'<body([^>]*)>', html) |
| 101 | body_attrs = body_attrs_match.group(1).strip() if body_attrs_match else '' |
| 102 | |
| 103 | # Build baseof.html preserving the original <head> structure |
| 104 | head_match = re.search(r'<head[^>]*>(.*?)</head>', html, re.DOTALL) |
| 105 | if head_mat |
| --- a/hugoifier/utils/parser.py | ||
| +++ b/hugoifier/utils/parser.py | ||
| @@ -0,0 +1,32 @@ | ||
| 1 | +""" | |
| 2 | +Performs parsing, linting, and validation of web content to ensure it adheres | |
| 3 | +to best practices and standards. Checks for syntax errors and structural issues. | |
| 4 | +""" | |
| 5 | + | |
| 6 | +import logging | |
| 7 | + | |
| 8 | + | |
| 9 | +# Function to perform parsing, linting, and validation | |
| 10 | +def parse(path): | |
| 11 | + logging.info(f"Starting parsing and linting for {path}...") | |
| 12 | + try: | |
| 13 | + # Parse input | |
| 14 | + logging.info("Parsing input...") | |
| 15 | + # Example parsing logic | |
| 16 | + # parse_html_structure(path) | |
| 17 | + | |
| 18 | + # Lint code | |
| 19 | + logging.info("Linting code...") | |
| 20 | + # Example linting logic | |
| 21 | + # lint_html_css_js(path) | |
| 22 | + | |
| 23 | + # Validate structure | |
| 24 | + logging.info("Validating structure...") | |
| 25 | + # Example validation logic | |
| 26 | + # validate_html_structure(path) | |
| 27 | + | |
| 28 | + logging.info("Parsing complete.") | |
| 29 | + return "Parsing complete" | |
| 30 | + except Exception as e: | |
| 31 | + logging.error(f"Error during parsing: {e}") | |
| 32 | + return "Parsing failed" |
| --- a/hugoifier/utils/parser.py | |
| +++ b/hugoifier/utils/parser.py | |
| @@ -0,0 +1,32 @@ | |
| --- a/hugoifier/utils/parser.py | |
| +++ b/hugoifier/utils/parser.py | |
| @@ -0,0 +1,32 @@ | |
| 1 | """ |
| 2 | Performs parsing, linting, and validation of web content to ensure it adheres |
| 3 | to best practices and standards. Checks for syntax errors and structural issues. |
| 4 | """ |
| 5 | |
| 6 | import logging |
| 7 | |
| 8 | |
| 9 | # Function to perform parsing, linting, and validation |
| 10 | def parse(path): |
| 11 | logging.info(f"Starting parsing and linting for {path}...") |
| 12 | try: |
| 13 | # Parse input |
| 14 | logging.info("Parsing input...") |
| 15 | # Example parsing logic |
| 16 | # parse_html_structure(path) |
| 17 | |
| 18 | # Lint code |
| 19 | logging.info("Linting code...") |
| 20 | # Example linting logic |
| 21 | # lint_html_css_js(path) |
| 22 | |
| 23 | # Validate structure |
| 24 | logging.info("Validating structure...") |
| 25 | # Example validation logic |
| 26 | # validate_html_structure(path) |
| 27 | |
| 28 | logging.info("Parsing complete.") |
| 29 | return "Parsing complete" |
| 30 | except Exception as e: |
| 31 | logging.error(f"Error during parsing: {e}") |
| 32 | return "Parsing failed" |
| --- a/hugoifier/utils/theme_finder.py | ||
| +++ b/hugoifier/utils/theme_finder.py | ||
| @@ -0,0 +1,74 @@ | ||
| 1 | +""" | |
| 2 | +Locates the actual Hugo theme and exampleSite within the messy zip-extracted structure. | |
| 3 | +Themes in themes/ are structured as: {name}/{name}/themes/{theme-name}/ | |
| 4 | +""" | |
| 5 | + | |
| 6 | +import }/ | |
| 7 | +""" | |
| 8 | + | |
| 9 | +import json | |
| 10 | +import logging | |
| 11 | +import os | |
| 12 | + | |
| 13 | + | |
| 14 | +def find_hugo_theme(input_path): | |
| 15 | + """ | |
| 16 | + Given a path like themes/revolve-hugo or the inner extracted dir, | |
| 17 | + find the Hugo theme directory (has layouts/), the exampleSite, and the theme name. | |
| 18 | + | |
| 19 | + Returns dict with: | |
| 20 | + theme_dir: path to the Hugo theme (has layouts/, archetypes/, etc.) | |
| 21 | + example_site: path to exampleSite dir (may be None) | |
| 22 | + theme_name: name of the theme (used in hugo.toml) | |
| 23 | + is_hugo_theme: True if input is already a Hugo theme | |
| 24 | + """ | |
| 25 | + input_path = os.path.abspath(input_path) | |
| 26 | + | |
| 27 | + # Walk up to find the theme dir containing layouts/ | |
| 28 | + candidates = [] | |
| 29 | + for root, dirs, files in os.walk(input_path): | |
| 30 | + # Skip __MACOSX junk | |
| 31 | + if '__MACOSX' in root: | |
| 32 | + continue | |
| 33 | + if 'layouts' in dirs and '_default' in os.listdir(os.path.join(root, 'layouts')): | |
| 34 | + candidates.append(root) | |
| 35 | + | |
| 36 | + if not candidates: | |
| 37 | + return None | |
| 38 | + | |
| 39 | + # Pick the deepest match (most likely the actual theme dir) | |
| 40 | + theme_dir = max(candidates, key=lambda p: p.count(os.sep)) | |
| 41 | + if len(candidates) > 1: | |
| 42 | + logging.warning( | |
| 43 | + f"Multiple Hugo theme candidates found; using {theme_dir!r}. " | |
| 44 | + f"Others: {[c for c in candidates if c != theme_dir]}" | |
| 45 | + ) | |
| 46 | + | |
| 47 | + # Detect exampleSite | |
| 48 | + example_site = None | |
| 49 | + for candidate in [ | |
| 50 | + os.path.join(theme_dir, 'exampleSite'), | |
| 51 | + os.path.join(os.path.dirname(theme_dir), 'exampleSite'), | |
| 52 | + ]: | |
| 53 | + if os.path.isdir(candidate): | |
| 54 | + example_site = candidate | |
| 55 | + break | |
| 56 | + | |
| 57 | + theme_name = os.path.basename(theme_dir) | |
| 58 | + | |
| 59 | + return { | |
| 60 | + 'theme_dir': theme_dir, | |
| 61 | + 'example_site': example_site, | |
| 62 | + 'theme_name': theme_name, | |
| 63 | + 'is_hugo_them return None | |
| 64 | + | |
| 65 | + | |
| 66 | +def find_raw_html_files(input_path): | |
| 67 | + """Find HTML files in a raw HTML theme (not a Hugo theme).""" | |
| 68 | + html_files = [] | |
| 69 | + for root, dirs, files in os.walk(input_path): | |
| 70 | + if '__MACOSX' in root: | |
| 71 | + continue | |
| 72 | + for f in files: | |
| 73 | + if f.endswith('.html') and 'exampleSite' not in root: | |
| 74 | + html_files.append(os.path.join(root, |
| --- a/hugoifier/utils/theme_finder.py | |
| +++ b/hugoifier/utils/theme_finder.py | |
| @@ -0,0 +1,74 @@ | |
| --- a/hugoifier/utils/theme_finder.py | |
| +++ b/hugoifier/utils/theme_finder.py | |
| @@ -0,0 +1,74 @@ | |
| 1 | """ |
| 2 | Locates the actual Hugo theme and exampleSite within the messy zip-extracted structure. |
| 3 | Themes in themes/ are structured as: {name}/{name}/themes/{theme-name}/ |
| 4 | """ |
| 5 | |
| 6 | import }/ |
| 7 | """ |
| 8 | |
| 9 | import json |
| 10 | import logging |
| 11 | import os |
| 12 | |
| 13 | |
| 14 | def find_hugo_theme(input_path): |
| 15 | """ |
| 16 | Given a path like themes/revolve-hugo or the inner extracted dir, |
| 17 | find the Hugo theme directory (has layouts/), the exampleSite, and the theme name. |
| 18 | |
| 19 | Returns dict with: |
| 20 | theme_dir: path to the Hugo theme (has layouts/, archetypes/, etc.) |
| 21 | example_site: path to exampleSite dir (may be None) |
| 22 | theme_name: name of the theme (used in hugo.toml) |
| 23 | is_hugo_theme: True if input is already a Hugo theme |
| 24 | """ |
| 25 | input_path = os.path.abspath(input_path) |
| 26 | |
| 27 | # Walk up to find the theme dir containing layouts/ |
| 28 | candidates = [] |
| 29 | for root, dirs, files in os.walk(input_path): |
| 30 | # Skip __MACOSX junk |
| 31 | if '__MACOSX' in root: |
| 32 | continue |
| 33 | if 'layouts' in dirs and '_default' in os.listdir(os.path.join(root, 'layouts')): |
| 34 | candidates.append(root) |
| 35 | |
| 36 | if not candidates: |
| 37 | return None |
| 38 | |
| 39 | # Pick the deepest match (most likely the actual theme dir) |
| 40 | theme_dir = max(candidates, key=lambda p: p.count(os.sep)) |
| 41 | if len(candidates) > 1: |
| 42 | logging.warning( |
| 43 | f"Multiple Hugo theme candidates found; using {theme_dir!r}. " |
| 44 | f"Others: {[c for c in candidates if c != theme_dir]}" |
| 45 | ) |
| 46 | |
| 47 | # Detect exampleSite |
| 48 | example_site = None |
| 49 | for candidate in [ |
| 50 | os.path.join(theme_dir, 'exampleSite'), |
| 51 | os.path.join(os.path.dirname(theme_dir), 'exampleSite'), |
| 52 | ]: |
| 53 | if os.path.isdir(candidate): |
| 54 | example_site = candidate |
| 55 | break |
| 56 | |
| 57 | theme_name = os.path.basename(theme_dir) |
| 58 | |
| 59 | return { |
| 60 | 'theme_dir': theme_dir, |
| 61 | 'example_site': example_site, |
| 62 | 'theme_name': theme_name, |
| 63 | 'is_hugo_them return None |
| 64 | |
| 65 | |
| 66 | def find_raw_html_files(input_path): |
| 67 | """Find HTML files in a raw HTML theme (not a Hugo theme).""" |
| 68 | html_files = [] |
| 69 | for root, dirs, files in os.walk(input_path): |
| 70 | if '__MACOSX' in root: |
| 71 | continue |
| 72 | for f in files: |
| 73 | if f.endswith('.html') and 'exampleSite' not in root: |
| 74 | html_files.append(os.path.join(root, |
| --- a/hugoifier/utils/theme_patcher.py | ||
| +++ b/hugoifier/utils/theme_patcher.py | ||
| @@ -0,0 +1,72 @@ | ||
| 1 | +""" | |
| 2 | +Patches common Hugo deprecations in theme layout files so they work with Hugo >= v0.128. | |
| 3 | + | |
| 4 | +Call patch_theme(theme_dir) after copying theme files to the output directory. | |
| 5 | +""" | |
| 6 | + | |
| 7 | +import logging | |
| 8 | +import os | |
| 9 | +import re | |
| 10 | + | |
| 11 | +# Map of (pattern, replacement) for deprecated Hugo template variables/functions | |
| 12 | +TEMPLATE_PATCHES = [ | |
| 13 | + # .Site.DisqusShortname → .Site.Config.Services.Disqus.Shortname | |
| 14 | + (r'\.Site\.DisqusShortname', '.Site.Config.Services.Disqus.Shortname'), | |
| 15 | + # .Site.GoogleAnalytics → .Site.Config.Services.GoogleAnalytics.ID | |
| 16 | + (r'\.Site\.GoogleAnalytics\b', '.Site.Config.Services.GoogleAnalytics.ID'), | |
| 17 | + # absLangURL → absLangURL still works but absURL is preferred for simple cases | |
| 18 | + # safeHTMLAttr is fine, no change needed | |
| 19 | +] | |
| 20 | + | |
| 21 | +# Config key patches: (old_pattern, replacement) | |
| 22 | +CONFIG_PATCHES = [ | |
| 23 | + # paginate → [pagination] pagerSize | |
| 24 | + (r'^paginate\s*=\s*(\d+)$', r'[pagination]\n pagerSize = \1'), | |
| 25 | + # googleAnalytics = "UA-xxx" → [services.googleAnalytics] id = "UA-xxx" | |
| 26 | + (r'^googleAnalytics\s*=\s*"([^"]+)"', r'[services.googleAnalytics]\n id = "\1"'), | |
| 27 | + # disqusShortname = "xxx" → [services.disqus] shortname = "xxx" | |
| 28 | + (r'^disqusShortname\s*=\s*"([^"]+)"', r'[services.disqus]\n shortname = "\1"'), | |
| 29 | +] | |
| 30 | + | |
| 31 | + | |
| 32 | +def patch_theme(theme_dir: str): | |
| 33 | + """Patch deprecated Hugo APIs in all layout files under theme_dir/layouts/.""" | |
| 34 | + layouts_dir = os.path.join(theme_dir, 'layouts') | |
| 35 | + if not os.path.isdir(layouts_dir): | |
| 36 | + return | |
| 37 | + | |
| 38 | + patched = 0 | |
| 39 | + for root, dirs, files in os.walk(layouts_dir): | |
| 40 | + for fname in files: | |
| 41 | + if not fname.endswith('.html'): | |
| 42 | + continue | |
| 43 | + path = os.path.join(root, fname) | |
| 44 | + with open(path, 'r', errors='replace') as f: | |
| 45 | + content = f.read() | |
| 46 | + | |
| 47 | + new_content = content | |
| 48 | + for pattern, replacement in TEMPLATE_PATCHES: | |
| 49 | + new_content = re.sub(pattern, replacement, new_content) | |
| 50 | + | |
| 51 | + if new_content != content: | |
| 52 | + with open(path, 'w') as f: | |
| 53 | + f.write(new_content) | |
| 54 | + patched += 1 | |
| 55 | + | |
| 56 | + if patched: | |
| 57 | + logging.info(f"Patched {patched} template file(s) in {theme_dir}") | |
| 58 | + | |
| 59 | + | |
| 60 | +def patch_config(config_path: str): | |
| 61 | + """Patch deprecated keys in a hugo.toml / config.toml file.""" | |
| 62 | + with open(config_path, 'r') as f: | |
| 63 | + content = f.read() | |
| 64 | + | |
| 65 | + new_content = content | |
| 66 | + for pattern, replacement in CONFIG_PATCHES: | |
| 67 | + new_content = re.sub(pattern, replacement, new_content, flags=re.MULTILINE) | |
| 68 | + | |
| 69 | + if new_content != content: | |
| 70 | + with open(config_path, 'w') as f: | |
| 71 | + f.write(new_content) | |
| 72 | + logging.info(f"Patched deprecated config keys in {config_path}") |
| --- a/hugoifier/utils/theme_patcher.py | |
| +++ b/hugoifier/utils/theme_patcher.py | |
| @@ -0,0 +1,72 @@ | |
| --- a/hugoifier/utils/theme_patcher.py | |
| +++ b/hugoifier/utils/theme_patcher.py | |
| @@ -0,0 +1,72 @@ | |
| 1 | """ |
| 2 | Patches common Hugo deprecations in theme layout files so they work with Hugo >= v0.128. |
| 3 | |
| 4 | Call patch_theme(theme_dir) after copying theme files to the output directory. |
| 5 | """ |
| 6 | |
| 7 | import logging |
| 8 | import os |
| 9 | import re |
| 10 | |
| 11 | # Map of (pattern, replacement) for deprecated Hugo template variables/functions |
| 12 | TEMPLATE_PATCHES = [ |
| 13 | # .Site.DisqusShortname → .Site.Config.Services.Disqus.Shortname |
| 14 | (r'\.Site\.DisqusShortname', '.Site.Config.Services.Disqus.Shortname'), |
| 15 | # .Site.GoogleAnalytics → .Site.Config.Services.GoogleAnalytics.ID |
| 16 | (r'\.Site\.GoogleAnalytics\b', '.Site.Config.Services.GoogleAnalytics.ID'), |
| 17 | # absLangURL → absLangURL still works but absURL is preferred for simple cases |
| 18 | # safeHTMLAttr is fine, no change needed |
| 19 | ] |
| 20 | |
| 21 | # Config key patches: (old_pattern, replacement) |
| 22 | CONFIG_PATCHES = [ |
| 23 | # paginate → [pagination] pagerSize |
| 24 | (r'^paginate\s*=\s*(\d+)$', r'[pagination]\n pagerSize = \1'), |
| 25 | # googleAnalytics = "UA-xxx" → [services.googleAnalytics] id = "UA-xxx" |
| 26 | (r'^googleAnalytics\s*=\s*"([^"]+)"', r'[services.googleAnalytics]\n id = "\1"'), |
| 27 | # disqusShortname = "xxx" → [services.disqus] shortname = "xxx" |
| 28 | (r'^disqusShortname\s*=\s*"([^"]+)"', r'[services.disqus]\n shortname = "\1"'), |
| 29 | ] |
| 30 | |
| 31 | |
| 32 | def patch_theme(theme_dir: str): |
| 33 | """Patch deprecated Hugo APIs in all layout files under theme_dir/layouts/.""" |
| 34 | layouts_dir = os.path.join(theme_dir, 'layouts') |
| 35 | if not os.path.isdir(layouts_dir): |
| 36 | return |
| 37 | |
| 38 | patched = 0 |
| 39 | for root, dirs, files in os.walk(layouts_dir): |
| 40 | for fname in files: |
| 41 | if not fname.endswith('.html'): |
| 42 | continue |
| 43 | path = os.path.join(root, fname) |
| 44 | with open(path, 'r', errors='replace') as f: |
| 45 | content = f.read() |
| 46 | |
| 47 | new_content = content |
| 48 | for pattern, replacement in TEMPLATE_PATCHES: |
| 49 | new_content = re.sub(pattern, replacement, new_content) |
| 50 | |
| 51 | if new_content != content: |
| 52 | with open(path, 'w') as f: |
| 53 | f.write(new_content) |
| 54 | patched += 1 |
| 55 | |
| 56 | if patched: |
| 57 | logging.info(f"Patched {patched} template file(s) in {theme_dir}") |
| 58 | |
| 59 | |
| 60 | def patch_config(config_path: str): |
| 61 | """Patch deprecated keys in a hugo.toml / config.toml file.""" |
| 62 | with open(config_path, 'r') as f: |
| 63 | content = f.read() |
| 64 | |
| 65 | new_content = content |
| 66 | for pattern, replacement in CONFIG_PATCHES: |
| 67 | new_content = re.sub(pattern, replacement, new_content, flags=re.MULTILINE) |
| 68 | |
| 69 | if new_content != content: |
| 70 | with open(config_path, 'w') as f: |
| 71 | f.write(new_content) |
| 72 | logging.info(f"Patched deprecated config keys in {config_path}") |
| --- a/hugoifier/utils/translate.py | ||
| +++ b/hugoifier/utils/translate.py | ||
| @@ -0,0 +1,25 @@ | ||
| 1 | +""" | |
| 2 | +Translates web content using the configured AI backend. | |
| 3 | +""" | |
| 4 | + | |
| 5 | +import logging | |
| 6 | + | |
| 7 | +from ..config import call_ai | |
| 8 | + | |
| 9 | + | |
| 10 | +def translate(path: str, target_language: str = "Spanish") -> str: | |
| 11 | + logging.info(f"Translating content in {path} ...") | |
| 12 | + try: | |
| 13 | + with open(path, 'r', errors='replace') as f: | |
| 14 | + content = f.read() | |
| 15 | + | |
| 16 | + prompt = f"""Translate the following web content to {target_language}. | |
| 17 | +Preserve all HTML tags and formatting. Only translate visible text. | |
| 18 | + | |
| 19 | +Content: | |
| 20 | +{content[:20000]}""" | |
| 21 | + | |
| 22 | + return call_ai(prompt, "You are a professional translator.") | |
| 23 | + except Exception as e: | |
| 24 | + logging.error(f"Translation failed: {e}") | |
| 25 | + return f"Translation failed: {e}" |
| --- a/hugoifier/utils/translate.py | |
| +++ b/hugoifier/utils/translate.py | |
| @@ -0,0 +1,25 @@ | |
| --- a/hugoifier/utils/translate.py | |
| +++ b/hugoifier/utils/translate.py | |
| @@ -0,0 +1,25 @@ | |
| 1 | """ |
| 2 | Translates web content using the configured AI backend. |
| 3 | """ |
| 4 | |
| 5 | import logging |
| 6 | |
| 7 | from ..config import call_ai |
| 8 | |
| 9 | |
| 10 | def translate(path: str, target_language: str = "Spanish") -> str: |
| 11 | logging.info(f"Translating content in {path} ...") |
| 12 | try: |
| 13 | with open(path, 'r', errors='replace') as f: |
| 14 | content = f.read() |
| 15 | |
| 16 | prompt = f"""Translate the following web content to {target_language}. |
| 17 | Preserve all HTML tags and formatting. Only translate visible text. |
| 18 | |
| 19 | Content: |
| 20 | {content[:20000]}""" |
| 21 | |
| 22 | return call_ai(prompt, "You are a professional translator.") |
| 23 | except Exception as e: |
| 24 | logging.error(f"Translation failed: {e}") |
| 25 | return f"Translation failed: {e}" |
+73
| --- a/pyproject.toml | ||
| +++ b/pyproject.toml | ||
| @@ -0,0 +1,73 @@ | ||
| 1 | +[build-system] | |
| 2 | +requires = ["setuptools>=69.0", "wheel"] | |
| 3 | +build-backend = "setuptools.build_meta" | |
| 4 | + | |
| 5 | +[project] | |
| 6 | +name = "hugoifier" | |
| 7 | +version = "0.1.0" | |
| 8 | +description = "AI-powered Hugo theme converter with Decap CMS integration" | |
| 9 | +readme = "README.md" | |
| 10 | +license = "MIT" | |
| 11 | +requires-python = ">=3.11" | |
| 12 | +authors = [ | |
| 13 | + { name = "CONFLICT LLC" }, | |
| 14 | +] | |
| 15 | +keywords = ["hugo", "cms", "decap", "static-site", "theme", "ai", "converter"] | |
| 16 | +classifiers = [ | |
| 17 | + "Development Status :: 3 - Alpha", | |
| 18 | + "Intended Audience :: Developers", | |
| 19 | + "Operating System :: OS Independent", | |
| 20 | + "Programming Language :: Python :: 3", | |
| 21 | + "Programming Language :: Python :: 3.11", | |
| 22 | + "Programming Language :: Python :: 3.12", | |
| 23 | + "Programming Language :: Python :: 3.13", | |
| 24 | + "Topic :: Internet :: WWW/HTTP :: Site Management", | |
| 25 | + "Topic :: Scientific/Engineering :: Artificial Intelligence", | |
| 26 | +] | |
| 27 | + | |
| 28 | +dependencies = [ | |
| 29 | + "anthropic>=0.5.0", | |
| 30 | + "openai>=1.0.0", | |
| 31 | + "google-generativeai>=0.3.0", | |
| 32 | + "pyyaml>=6.0", | |
| 33 | +] | |
| 34 | + | |
| 35 | +[project.optional-dependencies] | |
| 36 | +dev = [ | |
| 37 | + "pytest>=7.3.0", | |
| 38 | + "pytest-cov>=4.1.0", | |
| 39 | + "ruff>=0.1.0", | |
| 40 | + "build>=1.0.0", | |
| 41 | + "twine>=4.0.0", | |
| 42 | +] | |
| 43 | + | |
| 44 | +[project.urls] | |
| 45 | +Homepage = "https://hugoifier.dev" | |
| 46 | +Documentation = "https://hugoifier.dev" | |
| 47 | +Repository = "https://github.com/ConflictHQ/hugoifier" | |
| 48 | +Issues = "https://github.com/ConflictHQ/hugoifier/issues" | |
| 49 | + | |
| 50 | +[project.scripts] | |
| 51 | +hugoifier = "hugoifier.cli:main" | |
| 52 | + | |
| 53 | +[tool.setuptools.packages.find] | |
| 54 | +include = ["hugoifier*"] | |
| 55 | + | |
| 56 | +[tool.ruff] | |
| 57 | +line-length = 100 | |
| 58 | +target-version = "py311" | |
| 59 | + | |
| 60 | +[tool.ruff.lint] | |
| 61 | +select = ["E", "F", "W", "I"] | |
| 62 | + | |
| 63 | +[tool.ruff.lint.per-file-ignores] | |
| 64 | +"hugoifier/cli.py" = ["E501"] | |
| 65 | +"hugoifier/utitests/*" = ["E501"] | |
| 66 | + | |
| 67 | +[tool.pytest.ini_options] | |
| 68 | +testpaths = ["tests"] | |
| 69 | +python_files = "test_*.py" | |
| 70 | + | |
| 71 | +[tool.mypy] | |
| 72 | +python_version = "3.11" | |
| 73 | +warn_return_any = true |
| --- a/pyproject.toml | |
| +++ b/pyproject.toml | |
| @@ -0,0 +1,73 @@ | |
| --- a/pyproject.toml | |
| +++ b/pyproject.toml | |
| @@ -0,0 +1,73 @@ | |
| 1 | [build-system] |
| 2 | requires = ["setuptools>=69.0", "wheel"] |
| 3 | build-backend = "setuptools.build_meta" |
| 4 | |
| 5 | [project] |
| 6 | name = "hugoifier" |
| 7 | version = "0.1.0" |
| 8 | description = "AI-powered Hugo theme converter with Decap CMS integration" |
| 9 | readme = "README.md" |
| 10 | license = "MIT" |
| 11 | requires-python = ">=3.11" |
| 12 | authors = [ |
| 13 | { name = "CONFLICT LLC" }, |
| 14 | ] |
| 15 | keywords = ["hugo", "cms", "decap", "static-site", "theme", "ai", "converter"] |
| 16 | classifiers = [ |
| 17 | "Development Status :: 3 - Alpha", |
| 18 | "Intended Audience :: Developers", |
| 19 | "Operating System :: OS Independent", |
| 20 | "Programming Language :: Python :: 3", |
| 21 | "Programming Language :: Python :: 3.11", |
| 22 | "Programming Language :: Python :: 3.12", |
| 23 | "Programming Language :: Python :: 3.13", |
| 24 | "Topic :: Internet :: WWW/HTTP :: Site Management", |
| 25 | "Topic :: Scientific/Engineering :: Artificial Intelligence", |
| 26 | ] |
| 27 | |
| 28 | dependencies = [ |
| 29 | "anthropic>=0.5.0", |
| 30 | "openai>=1.0.0", |
| 31 | "google-generativeai>=0.3.0", |
| 32 | "pyyaml>=6.0", |
| 33 | ] |
| 34 | |
| 35 | [project.optional-dependencies] |
| 36 | dev = [ |
| 37 | "pytest>=7.3.0", |
| 38 | "pytest-cov>=4.1.0", |
| 39 | "ruff>=0.1.0", |
| 40 | "build>=1.0.0", |
| 41 | "twine>=4.0.0", |
| 42 | ] |
| 43 | |
| 44 | [project.urls] |
| 45 | Homepage = "https://hugoifier.dev" |
| 46 | Documentation = "https://hugoifier.dev" |
| 47 | Repository = "https://github.com/ConflictHQ/hugoifier" |
| 48 | Issues = "https://github.com/ConflictHQ/hugoifier/issues" |
| 49 | |
| 50 | [project.scripts] |
| 51 | hugoifier = "hugoifier.cli:main" |
| 52 | |
| 53 | [tool.setuptools.packages.find] |
| 54 | include = ["hugoifier*"] |
| 55 | |
| 56 | [tool.ruff] |
| 57 | line-length = 100 |
| 58 | target-version = "py311" |
| 59 | |
| 60 | [tool.ruff.lint] |
| 61 | select = ["E", "F", "W", "I"] |
| 62 | |
| 63 | [tool.ruff.lint.per-file-ignores] |
| 64 | "hugoifier/cli.py" = ["E501"] |
| 65 | "hugoifier/utitests/*" = ["E501"] |
| 66 | |
| 67 | [tool.pytest.ini_options] |
| 68 | testpaths = ["tests"] |
| 69 | python_files = "test_*.py" |
| 70 | |
| 71 | [tool.mypy] |
| 72 | python_version = "3.11" |
| 73 | warn_return_any = true |
A
setup.py
+5
| --- a/setup.py | ||
| +++ b/setup.py | ||
| @@ -0,0 +1,5 @@ | ||
| 1 | +"""Backwards-compatible setup.py — all config lives in pyproject.toml.""" | |
| 2 | + | |
| 3 | +from setuptools import setup | |
| 4 | + | |
| 5 | +setup() |
| --- a/setup.py | |
| +++ b/setup.py | |
| @@ -0,0 +1,5 @@ | |
| --- a/setup.py | |
| +++ b/setup.py | |
| @@ -0,0 +1,5 @@ | |
| 1 | """Backwards-compatible setup.py — all config lives in pyproject.toml.""" |
| 2 | |
| 3 | from setuptools import setup |
| 4 | |
| 5 | setup() |
D
src/cli.py
-131
| --- a/src/cli.py | ||
| +++ b/src/cli.py | ||
| @@ -1,131 +0,0 @@ | ||
| 1 | -""" | |
| 2 | -Hugo-ifier CLI Tool | |
| 3 | - | |
| 4 | -Usage examples: | |
| 5 | - python cli.py complete themes/revolve-hugo | |
| 6 | - python cli.py complete themes/revolve-hugo --output /tmp/my-site | |
| 7 | - HUGOIFIER_BACKEND=openai python cli.py complete themes/revolve-hugo | |
| 8 | - python cli.py analyze themes/revolve-hugo | |
| 9 | - python cli.py hugoify themes/revolve-hugo | |
| 10 | - python cli.py decapify output/revolve-hugo | |
| 11 | -""" | |
| 12 | - | |
| 13 | -import argparse | |
| 14 | -import logging | |
| 15 | -import sys | |
| 16 | -import os | |
| 17 | - | |
| 18 | -# Ensure src/ is on the path when called directly | |
| 19 | -sys.path.insert(0, os.path.dirname(__file__)) | |
| 20 | - | |
| 21 | -from utils.analyze import analyze | |
| 22 | -from utils.complete import complete | |
| 23 | -from utils.cloudflare import configure_cloudflare | |
| 24 | -from utils.deploy import deploy | |
| 25 | -from utils.hugoify import hugoify | |
| 26 | -from utils.decapify import decapify | |
| 27 | -from utils.translate import translate | |
| 28 | -from utils.parser import parse | |
| 29 | - | |
| 30 | - | |
| 31 | -def main(): | |
| 32 | - parser = argparse.ArgumentParser( | |
| 33 | - description="Hugo-ifier — AI-powered Hugo theme converter", | |
| 34 | - formatter_class=argparse.RawDescriptionHelpFormatter, | |
| 35 | - epilog=__doc__, | |
| 36 | - ) | |
| 37 | - parser.add_argument( | |
| 38 | - '--backend', choices=['anthropic', 'openai', 'google'], | |
| 39 | - help='AI backend to use (overrides HUGOIFIER_BACKEND env var)', | |
| 40 | - ) | |
| 41 | - subparsers = parser.add_subparsers(dest="command") | |
| 42 | - | |
| 43 | - # complete — full pipeline | |
| 44 | - complete_parser = subparsers.add_parser("complete", help="Run the full pipeline (analyze → hugoify → decap)") | |
| 45 | - complete_parser.add_argument("path", help="Path to the theme directory") | |
| 46 | - complete_parser.add_argument("--output", "-o", help="Output directory (default: output/{theme-name})") | |
| 47 | - complete_parser.add_argument("--cms-name", default=None, help="Whitelabel CMS name") | |
| 48 | - complete_parser.add_argument("--cms-logo", default=None, help="Whitelabel logo URL") | |
| 49 | - complete_parser.add_argument("--cms-color", default=None, help="Whitelabel top-bar hex color") | |
| 50 | - | |
| 51 | - # analyze | |
| 52 | - analyze_parser = subparsers.add_parser("analyze", help="Analyze a theme and report structure") | |
| 53 | - analyze_parser.add_argument("path", help="Path to the theme") | |
| 54 | - | |
| 55 | - # hugoify | |
| 56 | - hugoify_parser = subparsers.add_parser("hugoify", help="Convert HTML to Hugo theme (or validate existing Hugo theme)") | |
| 57 | - hugoify_parser.add_argument("path", help="Path to HTML file or theme directory") | |
| 58 | - | |
| 59 | - # decapify | |
| 60 | - decapify_parser = subparsers.add_parser("decapify", help="Add Decap CMS to an assembled Hugo site") | |
| 61 | - decapify_parser.add_argument("path", help="Path to the Hugo site directory") | |
| 62 | - decapify_parser.add_argument("--cms-name", default=None, help="Whitelabel CMS name (default: 'Content Manager')") | |
| 63 | - decapify_parser.add_argument("--cms-logo", default=None, help="Whitelabel logo URL") | |
| 64 | - decapify_parser.add_argument("--cms-color", default=None, help="Whitelabel top-bar hex color") | |
| 65 | - | |
| 66 | - # translate | |
| 67 | - translate_parser = subparsers.add_parser("translate", help="Translate content to another language") | |
| 68 | - translate_parser.add_argument("path", help="Path to the content file") | |
| 69 | - translate_parser.add_argument("--target-language", default="Spanish", help="Target language (default: Spanish)") | |
| 70 | - | |
| 71 | - # parse / lint | |
| 72 | - parser_parser = subparsers.add_parser("parser", help="Parse and lint (stub)") | |
| 73 | - parser_parser.add_argument("path", help="Path to the theme") | |
| 74 | - | |
| 75 | - # deploy (stub) | |
| 76 | - deploy_parser = subparsers.add_parser("deploy", help="Deploy to Cloudflare (stub)") | |
| 77 | - deploy_parser.add_argument("path", help="Path to the site") | |
| 78 | - deploy_parser.add_argument("zone", help="Cloudflare zone") | |
| 79 | - | |
| 80 | - # cloudflare (stub) | |
| 81 | - cloudflare_parser = subparsers.add_parser("cloudflare", help="Configure Cloudflare (stub)") | |
| 82 | - cloudflare_parser.add_argument("path", help="Path to the site") | |
| 83 | - cloudflare_parser.add_argument("zone", help="Cloudflare zone") | |
| 84 | - | |
| 85 | - args = parser.parse_args() | |
| 86 | - | |
| 87 | - logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') | |
| 88 | - | |
| 89 | - # Override backend if specified on command line | |
| 90 | - if args.backend: | |
| 91 | - import config as cfg | |
| 92 | - cfg.BACKEND = args.backend | |
| 93 | - | |
| 94 | - try: | |
| 95 | - if args.command == "complete": | |
| 96 | - result = complete( | |
| 97 | - args.path, | |
| 98 | - output_dir=args.output, | |
| 99 | - cms_name=args.cms_name, | |
| 100 | - cms_logo=args.cms_logo, | |
| 101 | - cms_color=args.cms_color, | |
| 102 | - ) | |
| 103 | - print(result) | |
| 104 | - elif args.command == "analyze": | |
| 105 | - print(analyze(args.path)) | |
| 106 | - elif args.command == "hugoify": | |
| 107 | - print(hugoify(args.path)) | |
| 108 | - elif args.command == "decapify": | |
| 109 | - print(decapify( | |
| 110 | - args.path, | |
| 111 | - cms_name=args.cms_name, | |
| 112 | - cms_logo=args.cms_logo, | |
| 113 | - cms_color=args.cms_color, | |
| 114 | - )) | |
| 115 | - elif args.command == "translate": | |
| 116 | - print(translate(args.path, target_language=args.target_language)) | |
| 117 | - elif args.command == "parser": | |
| 118 | - print(parse(args.path)) | |
| 119 | - elif args.command == "deploy": | |
| 120 | - print(deploy(args.path, args.zone)) | |
| 121 | - elif args.command == "cloudflare": | |
| 122 | - print(configure_cloudflare(args.path, args.zone)) | |
| 123 | - else: | |
| 124 | - parser.print_help() | |
| 125 | - except (ValueError, EnvironmentError) as e: | |
| 126 | - print(f"Error: {e}", file=sys.stderr) | |
| 127 | - sys.exit(1) | |
| 128 | - | |
| 129 | - | |
| 130 | -if __name__ == "__main__": | |
| 131 | - main() |
| --- a/src/cli.py | |
| +++ b/src/cli.py | |
| @@ -1,131 +0,0 @@ | |
| 1 | """ |
| 2 | Hugo-ifier CLI Tool |
| 3 | |
| 4 | Usage examples: |
| 5 | python cli.py complete themes/revolve-hugo |
| 6 | python cli.py complete themes/revolve-hugo --output /tmp/my-site |
| 7 | HUGOIFIER_BACKEND=openai python cli.py complete themes/revolve-hugo |
| 8 | python cli.py analyze themes/revolve-hugo |
| 9 | python cli.py hugoify themes/revolve-hugo |
| 10 | python cli.py decapify output/revolve-hugo |
| 11 | """ |
| 12 | |
| 13 | import argparse |
| 14 | import logging |
| 15 | import sys |
| 16 | import os |
| 17 | |
| 18 | # Ensure src/ is on the path when called directly |
| 19 | sys.path.insert(0, os.path.dirname(__file__)) |
| 20 | |
| 21 | from utils.analyze import analyze |
| 22 | from utils.complete import complete |
| 23 | from utils.cloudflare import configure_cloudflare |
| 24 | from utils.deploy import deploy |
| 25 | from utils.hugoify import hugoify |
| 26 | from utils.decapify import decapify |
| 27 | from utils.translate import translate |
| 28 | from utils.parser import parse |
| 29 | |
| 30 | |
| 31 | def main(): |
| 32 | parser = argparse.ArgumentParser( |
| 33 | description="Hugo-ifier — AI-powered Hugo theme converter", |
| 34 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 35 | epilog=__doc__, |
| 36 | ) |
| 37 | parser.add_argument( |
| 38 | '--backend', choices=['anthropic', 'openai', 'google'], |
| 39 | help='AI backend to use (overrides HUGOIFIER_BACKEND env var)', |
| 40 | ) |
| 41 | subparsers = parser.add_subparsers(dest="command") |
| 42 | |
| 43 | # complete — full pipeline |
| 44 | complete_parser = subparsers.add_parser("complete", help="Run the full pipeline (analyze → hugoify → decap)") |
| 45 | complete_parser.add_argument("path", help="Path to the theme directory") |
| 46 | complete_parser.add_argument("--output", "-o", help="Output directory (default: output/{theme-name})") |
| 47 | complete_parser.add_argument("--cms-name", default=None, help="Whitelabel CMS name") |
| 48 | complete_parser.add_argument("--cms-logo", default=None, help="Whitelabel logo URL") |
| 49 | complete_parser.add_argument("--cms-color", default=None, help="Whitelabel top-bar hex color") |
| 50 | |
| 51 | # analyze |
| 52 | analyze_parser = subparsers.add_parser("analyze", help="Analyze a theme and report structure") |
| 53 | analyze_parser.add_argument("path", help="Path to the theme") |
| 54 | |
| 55 | # hugoify |
| 56 | hugoify_parser = subparsers.add_parser("hugoify", help="Convert HTML to Hugo theme (or validate existing Hugo theme)") |
| 57 | hugoify_parser.add_argument("path", help="Path to HTML file or theme directory") |
| 58 | |
| 59 | # decapify |
| 60 | decapify_parser = subparsers.add_parser("decapify", help="Add Decap CMS to an assembled Hugo site") |
| 61 | decapify_parser.add_argument("path", help="Path to the Hugo site directory") |
| 62 | decapify_parser.add_argument("--cms-name", default=None, help="Whitelabel CMS name (default: 'Content Manager')") |
| 63 | decapify_parser.add_argument("--cms-logo", default=None, help="Whitelabel logo URL") |
| 64 | decapify_parser.add_argument("--cms-color", default=None, help="Whitelabel top-bar hex color") |
| 65 | |
| 66 | # translate |
| 67 | translate_parser = subparsers.add_parser("translate", help="Translate content to another language") |
| 68 | translate_parser.add_argument("path", help="Path to the content file") |
| 69 | translate_parser.add_argument("--target-language", default="Spanish", help="Target language (default: Spanish)") |
| 70 | |
| 71 | # parse / lint |
| 72 | parser_parser = subparsers.add_parser("parser", help="Parse and lint (stub)") |
| 73 | parser_parser.add_argument("path", help="Path to the theme") |
| 74 | |
| 75 | # deploy (stub) |
| 76 | deploy_parser = subparsers.add_parser("deploy", help="Deploy to Cloudflare (stub)") |
| 77 | deploy_parser.add_argument("path", help="Path to the site") |
| 78 | deploy_parser.add_argument("zone", help="Cloudflare zone") |
| 79 | |
| 80 | # cloudflare (stub) |
| 81 | cloudflare_parser = subparsers.add_parser("cloudflare", help="Configure Cloudflare (stub)") |
| 82 | cloudflare_parser.add_argument("path", help="Path to the site") |
| 83 | cloudflare_parser.add_argument("zone", help="Cloudflare zone") |
| 84 | |
| 85 | args = parser.parse_args() |
| 86 | |
| 87 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') |
| 88 | |
| 89 | # Override backend if specified on command line |
| 90 | if args.backend: |
| 91 | import config as cfg |
| 92 | cfg.BACKEND = args.backend |
| 93 | |
| 94 | try: |
| 95 | if args.command == "complete": |
| 96 | result = complete( |
| 97 | args.path, |
| 98 | output_dir=args.output, |
| 99 | cms_name=args.cms_name, |
| 100 | cms_logo=args.cms_logo, |
| 101 | cms_color=args.cms_color, |
| 102 | ) |
| 103 | print(result) |
| 104 | elif args.command == "analyze": |
| 105 | print(analyze(args.path)) |
| 106 | elif args.command == "hugoify": |
| 107 | print(hugoify(args.path)) |
| 108 | elif args.command == "decapify": |
| 109 | print(decapify( |
| 110 | args.path, |
| 111 | cms_name=args.cms_name, |
| 112 | cms_logo=args.cms_logo, |
| 113 | cms_color=args.cms_color, |
| 114 | )) |
| 115 | elif args.command == "translate": |
| 116 | print(translate(args.path, target_language=args.target_language)) |
| 117 | elif args.command == "parser": |
| 118 | print(parse(args.path)) |
| 119 | elif args.command == "deploy": |
| 120 | print(deploy(args.path, args.zone)) |
| 121 | elif args.command == "cloudflare": |
| 122 | print(configure_cloudflare(args.path, args.zone)) |
| 123 | else: |
| 124 | parser.print_help() |
| 125 | except (ValueError, EnvironmentError) as e: |
| 126 | print(f"Error: {e}", file=sys.stderr) |
| 127 | sys.exit(1) |
| 128 | |
| 129 | |
| 130 | if __name__ == "__main__": |
| 131 | main() |
| --- a/src/cli.py | |
| +++ b/src/cli.py | |
| @@ -1,131 +0,0 @@ | |
D
src/config.py
-38
| --- a/src/config.py | ||
| +++ b/src/config.py | ||
| @@ -1,38 +0,0 @@ | ||
| 1 | -""" | |
| 2 | -Multi-backend AI configuration. | |
| 3 | - | |
| 4 | -Set HUGOIFIER_BACKEND env var to switch backends: | |
| 5 | - anthropic (default) — claude-sonnet-4-6 | |
| 6 | - openai — gpt-4-turbo | |
| 7 | - google — gemini-1.5-pro | |
| 8 | - | |
| 9 | -Model can be overridden per-backend: | |
| 10 | - ANTHROPIC_MODEL, OPENAI_MODEL, GOOGLE_MODEL | |
| 11 | -""" | |
| 12 | - | |
| 13 | -import os | |
| 14 | - | |
| 15 | -BACKEND = os.getenv('HUGOIFIER_BACKEND', 'anthropic').lower() | |
| 16 | - | |
| 17 | -# Anthropic settings | |
| 18 | -ANTHROPIC_API_KEY = os.getenv('ANTHROPIC_API_KEY') | |
| 19 | -ANTHROPIC_MODEL = os.getenv('ANTHROPIC_MODEL', 'claude-sonnet-4-6') | |
| 20 | - | |
| 21 | -# OpenAI settings | |
| 22 | -OPENAI_API_KEY = os.getenv('OPENAI_API_KEY') | |
| 23 | -OPENAI_MODEL = os.getenv('OPENAI_MODEL', 'gpt-4-turbo') | |
| 24 | - | |
| 25 | -# Google settings | |
| 26 | -GOOGLE_API_KEY = os.getenv('GOOGLE_API_KEY') | |
| 27 | -GOOGLE_MODEL = os.getenv('GOOGLE_MODEL', 'gemini-1.5-pro') | |
| 28 | - | |
| 29 | -MAX_TOKENS = int(os.getenv('HUGOIFIER_MAX_TOKENS', '4096')) | |
| 30 | - | |
| 31 | - | |
| 32 | -def call_ai(prompt: str, system: str = "You are a helpful Hugo theme conversion assistant."x_tokens or MAX_TOKENS | |
| 33 | - if BACKEND == 'anthropic': | |
| 34 | - return _ca) | |
| 35 | - elif BACKEND == 'openai': | |
| 36 | - return _call_openai(prompt, system) | |
| 37 | - elif BACKEND == 'google': | |
| 38 | - retu |
| --- a/src/config.py | |
| +++ b/src/config.py | |
| @@ -1,38 +0,0 @@ | |
| 1 | """ |
| 2 | Multi-backend AI configuration. |
| 3 | |
| 4 | Set HUGOIFIER_BACKEND env var to switch backends: |
| 5 | anthropic (default) — claude-sonnet-4-6 |
| 6 | openai — gpt-4-turbo |
| 7 | google — gemini-1.5-pro |
| 8 | |
| 9 | Model can be overridden per-backend: |
| 10 | ANTHROPIC_MODEL, OPENAI_MODEL, GOOGLE_MODEL |
| 11 | """ |
| 12 | |
| 13 | import os |
| 14 | |
| 15 | BACKEND = os.getenv('HUGOIFIER_BACKEND', 'anthropic').lower() |
| 16 | |
| 17 | # Anthropic settings |
| 18 | ANTHROPIC_API_KEY = os.getenv('ANTHROPIC_API_KEY') |
| 19 | ANTHROPIC_MODEL = os.getenv('ANTHROPIC_MODEL', 'claude-sonnet-4-6') |
| 20 | |
| 21 | # OpenAI settings |
| 22 | OPENAI_API_KEY = os.getenv('OPENAI_API_KEY') |
| 23 | OPENAI_MODEL = os.getenv('OPENAI_MODEL', 'gpt-4-turbo') |
| 24 | |
| 25 | # Google settings |
| 26 | GOOGLE_API_KEY = os.getenv('GOOGLE_API_KEY') |
| 27 | GOOGLE_MODEL = os.getenv('GOOGLE_MODEL', 'gemini-1.5-pro') |
| 28 | |
| 29 | MAX_TOKENS = int(os.getenv('HUGOIFIER_MAX_TOKENS', '4096')) |
| 30 | |
| 31 | |
| 32 | def call_ai(prompt: str, system: str = "You are a helpful Hugo theme conversion assistant."x_tokens or MAX_TOKENS |
| 33 | if BACKEND == 'anthropic': |
| 34 | return _ca) |
| 35 | elif BACKEND == 'openai': |
| 36 | return _call_openai(prompt, system) |
| 37 | elif BACKEND == 'google': |
| 38 | retu |
| --- a/src/config.py | |
| +++ b/src/config.py | |
| @@ -1,38 +0,0 @@ | |
D
src/utils/analyze.py
-81
| --- a/src/utils/analyze.py | ||
| +++ b/src/utils/analyze.py | ||
| @@ -1,81 +0,0 @@ | ||
| 1 | -""" | |
| 2 | -Analyzes a Hugo theme or raw HTML theme and reports structure + recommendations. | |
| 3 | -""" | |
| 4 | - | |
| 5 | -import logging | |
| 6 | -import os | |
| 7 | - | |
| 8 | -from config import call_ai | |
| 9 | -from utils.theme_finder import find_hugo_theme, find_raw_html_files | |
| 10 | - | |
| 11 | -SYSTEM = "You are an expert Hugo theme developer analyzing themes for conversion." | |
| 12 | - | |
| 13 | - | |
| 14 | -def analyze(path: str) -> str: | |
| 15 | - logging.info(f"Analyzing {path} ...") | |
| 16 | - | |
| 17 | - info = find_hugo_theme(path) | |
| 18 | - | |
| 19 | - if info: | |
| 20 | - return _analyze_hugo_theme(info) | |
| 21 | - else: | |
| 22 | - return _analyze_raw_html(path) | |
| 23 | - | |
| 24 | - | |
| 25 | -def _analyze_hugo_theme(info: dict) -> str: | |
| 26 | - theme_dir = info['theme_dir'] | |
| 27 | - theme_name = info['theme_name'] | |
| 28 | - example_site = info['example_site'] | |
| 29 | - | |
| 30 | - # Collect layout files | |
| 31 | - layouts = [] | |
| 32 | - for root, dirs, files in os.walk(os.path.join(theme_dir, 'layouts')): | |
| 33 | - for f in files: | |
| 34 | - if f.endswith('.html'): | |
| 35 | - rel = os.path.relpath(os.path.join(root, f), theme_dir) | |
| 36 | - layouts.append(rel) | |
| 37 | - | |
| 38 | - # Collect content types from exampleSite | |
| 39 | - content_types = [] | |
| 40 | - if example_site: | |
| 41 | - content_dir = os.path.join(example_site, 'content') | |
| 42 | - if os.path.isdir(content_dir): | |
| 43 | - content_types = [d for d in os.listdir(content_dir) if os.path.isdir(os.path.join(content_dir, d))] | |
| 44 | - | |
| 45 | - report = [ | |
| 46 | - f"Theme: {theme_name}", | |
| 47 | - f"Theme dir: {theme_dir}", | |
| 48 | - f"Layouts ({len(layouts)}):", | |
| 49 | - *[f" {layout}" for layout in sorted(layouts)], | |
| 50 | - f"Content types: {content_types}", | |
| 51 | - f"ExampleSite: {example_site or 'none'}", | |
| 52 | - "", | |
| 53 | - "Status: Already a Hugo theme. Use 'complete' to assemble a working site.", | |
| 54 | - ] | |
| 55 | - return "\n".join(report) | |
| 56 | - | |
| 57 | - | |
| 58 | -def _analyze_raw_html(path: str) -> str: | |
| 59 | - html_files = find_raw_html_files(path) | |
| 60 | - if not html_files: | |
| 61 | - return f"No HTML files found at {path}" | |
| 62 | - | |
| 63 | - # Read main HTML file for AI analysis | |
| 64 | - main = next((f for f in html_files if os.path.basename(f).lower() == 'index.html'), html_files[0]) | |
| 65 | - with open(main, 'r', errors='replace') as f: | |
| 66 | - html = f.read()[:20000] | |
| 67 | - | |
| 68 | - prompt = f"""Analyze this HTML theme file and provide: | |
| 69 | -1. Identified reusable components (header, footer, nav, sidebar, etc.) | |
| 70 | -2. Recommended Hugo template tags for dynamic content | |
| 71 | -3. Suggested partial splits | |
| 72 | -4. Content sections that should be data/ YAML files for Decap CMS | |
| 73 | - | |
| 74 | -HTML: | |
| 75 | -{html}""" | |
| 76 | - | |
| 77 | - try: | |
| 78 | - return call_ai(prompt, SYSTEM) | |
| 79 | - except Exception as e: | |
| 80 | - logging.error(f"AI analysis failed: {e}") | |
| 81 | - return f"HTML theme with {len(html_files)} files. AI analysis failed: {e}" |
| --- a/src/utils/analyze.py | |
| +++ b/src/utils/analyze.py | |
| @@ -1,81 +0,0 @@ | |
| 1 | """ |
| 2 | Analyzes a Hugo theme or raw HTML theme and reports structure + recommendations. |
| 3 | """ |
| 4 | |
| 5 | import logging |
| 6 | import os |
| 7 | |
| 8 | from config import call_ai |
| 9 | from utils.theme_finder import find_hugo_theme, find_raw_html_files |
| 10 | |
| 11 | SYSTEM = "You are an expert Hugo theme developer analyzing themes for conversion." |
| 12 | |
| 13 | |
| 14 | def analyze(path: str) -> str: |
| 15 | logging.info(f"Analyzing {path} ...") |
| 16 | |
| 17 | info = find_hugo_theme(path) |
| 18 | |
| 19 | if info: |
| 20 | return _analyze_hugo_theme(info) |
| 21 | else: |
| 22 | return _analyze_raw_html(path) |
| 23 | |
| 24 | |
| 25 | def _analyze_hugo_theme(info: dict) -> str: |
| 26 | theme_dir = info['theme_dir'] |
| 27 | theme_name = info['theme_name'] |
| 28 | example_site = info['example_site'] |
| 29 | |
| 30 | # Collect layout files |
| 31 | layouts = [] |
| 32 | for root, dirs, files in os.walk(os.path.join(theme_dir, 'layouts')): |
| 33 | for f in files: |
| 34 | if f.endswith('.html'): |
| 35 | rel = os.path.relpath(os.path.join(root, f), theme_dir) |
| 36 | layouts.append(rel) |
| 37 | |
| 38 | # Collect content types from exampleSite |
| 39 | content_types = [] |
| 40 | if example_site: |
| 41 | content_dir = os.path.join(example_site, 'content') |
| 42 | if os.path.isdir(content_dir): |
| 43 | content_types = [d for d in os.listdir(content_dir) if os.path.isdir(os.path.join(content_dir, d))] |
| 44 | |
| 45 | report = [ |
| 46 | f"Theme: {theme_name}", |
| 47 | f"Theme dir: {theme_dir}", |
| 48 | f"Layouts ({len(layouts)}):", |
| 49 | *[f" {layout}" for layout in sorted(layouts)], |
| 50 | f"Content types: {content_types}", |
| 51 | f"ExampleSite: {example_site or 'none'}", |
| 52 | "", |
| 53 | "Status: Already a Hugo theme. Use 'complete' to assemble a working site.", |
| 54 | ] |
| 55 | return "\n".join(report) |
| 56 | |
| 57 | |
| 58 | def _analyze_raw_html(path: str) -> str: |
| 59 | html_files = find_raw_html_files(path) |
| 60 | if not html_files: |
| 61 | return f"No HTML files found at {path}" |
| 62 | |
| 63 | # Read main HTML file for AI analysis |
| 64 | main = next((f for f in html_files if os.path.basename(f).lower() == 'index.html'), html_files[0]) |
| 65 | with open(main, 'r', errors='replace') as f: |
| 66 | html = f.read()[:20000] |
| 67 | |
| 68 | prompt = f"""Analyze this HTML theme file and provide: |
| 69 | 1. Identified reusable components (header, footer, nav, sidebar, etc.) |
| 70 | 2. Recommended Hugo template tags for dynamic content |
| 71 | 3. Suggested partial splits |
| 72 | 4. Content sections that should be data/ YAML files for Decap CMS |
| 73 | |
| 74 | HTML: |
| 75 | {html}""" |
| 76 | |
| 77 | try: |
| 78 | return call_ai(prompt, SYSTEM) |
| 79 | except Exception as e: |
| 80 | logging.error(f"AI analysis failed: {e}") |
| 81 | return f"HTML theme with {len(html_files)} files. AI analysis failed: {e}" |
| --- a/src/utils/analyze.py | |
| +++ b/src/utils/analyze.py | |
| @@ -1,81 +0,0 @@ | |
D
src/utils/cloudflare.py
-30
| --- a/src/utils/cloudflare.py | ||
| +++ b/src/utils/cloudflare.py | ||
| @@ -1,30 +0,0 @@ | ||
| 1 | -""" | |
| 2 | -This script configures and deploys a Hugo site to Cloudflare, handling page creation, DNS settings, and deployment. | |
| 3 | -It uses Cloudflare's API to automate these tasks. | |
| 4 | -""" | |
| 5 | - | |
| 6 | -import logging | |
| 7 | - | |
| 8 | -# Function to configure and deploy to Cloudflare | |
| 9 | -def configure_cloudflare(path, zone): | |
| 10 | - logging.info(f"Starting Cloudflare configuration for {path} in zone {zone}...") | |
| 11 | - try: | |
| 12 | - # Placeholder logic for Cloudflare configuration | |
| 13 | - # This could involve API calls to Cloudflare to create pages, set DNS, etc. | |
| 14 | - logging.info("Creating Cloudflare page...") | |
| 15 | - # Example API call to create a page | |
| 16 | - # cloudflare_api.create_page(path, zone) | |
| 17 | - | |
| 18 | - logging.info("Deploying site...") | |
| 19 | - # Example API call to deploy the site | |
| 20 | - # cloudflare_api.deploy_site(path, zone) | |
| 21 | - | |
| 22 | - logging.info("Configuring DNS settings...") | |
| 23 | - # Example API call to configure DNS | |
| 24 | - # cloudflare_api.configure_dns(path, zone) | |
| 25 | - | |
| 26 | - logging.info("Cloudflare configuration complete.") | |
| 27 | - return "Cloudflare configuration complete" | |
| 28 | - except Exception as e: | |
| 29 | - logging.error(f"Error during Cloudflare configuration: {e}") | |
| 30 | - return "Cloudflare configuration failed" |
| --- a/src/utils/cloudflare.py | |
| +++ b/src/utils/cloudflare.py | |
| @@ -1,30 +0,0 @@ | |
| 1 | """ |
| 2 | This script configures and deploys a Hugo site to Cloudflare, handling page creation, DNS settings, and deployment. |
| 3 | It uses Cloudflare's API to automate these tasks. |
| 4 | """ |
| 5 | |
| 6 | import logging |
| 7 | |
| 8 | # Function to configure and deploy to Cloudflare |
| 9 | def configure_cloudflare(path, zone): |
| 10 | logging.info(f"Starting Cloudflare configuration for {path} in zone {zone}...") |
| 11 | try: |
| 12 | # Placeholder logic for Cloudflare configuration |
| 13 | # This could involve API calls to Cloudflare to create pages, set DNS, etc. |
| 14 | logging.info("Creating Cloudflare page...") |
| 15 | # Example API call to create a page |
| 16 | # cloudflare_api.create_page(path, zone) |
| 17 | |
| 18 | logging.info("Deploying site...") |
| 19 | # Example API call to deploy the site |
| 20 | # cloudflare_api.deploy_site(path, zone) |
| 21 | |
| 22 | logging.info("Configuring DNS settings...") |
| 23 | # Example API call to configure DNS |
| 24 | # cloudflare_api.configure_dns(path, zone) |
| 25 | |
| 26 | logging.info("Cloudflare configuration complete.") |
| 27 | return "Cloudflare configuration complete" |
| 28 | except Exception as e: |
| 29 | logging.error(f"Error during Cloudflare configuration: {e}") |
| 30 | return "Cloudflare configuration failed" |
| --- a/src/utils/cloudflare.py | |
| +++ b/src/utils/cloudflare.py | |
| @@ -1,30 +0,0 @@ | |
D
src/utils/complete.py
-239
| --- a/src/utils/complete.py | ||
| +++ b/src/utils/complete.py | ||
| @@ -1,239 +0,0 @@ | ||
| 1 | -""" | |
| 2 | -Full end-to-end pipeline: detect → copy → configure → decap. | |
| 3 | - | |
| 4 | -For already-Hugo themes: assembles a clean, standalone site. | |
| 5 | -For raw HTML themes: calls hugoify first, then assembles. | |
| 6 | -""" | |
| 7 | - | |
| 8 | -import logging | |
| 9 | -import os | |
| 10 | -import shutil | |
| 11 | -from pathlib import Path | |
| 12 | - | |
| 13 | -from utils.theme_finder import find_hugo_theme, find_raw_html_files | |
| 14 | -from utils.hugoify import hugoify_html | |
| 15 | -from utils.decapify import decapify | |
| 16 | -from utils.theme_patcher import patch_theme, patch_config | |
| 17 | - | |
| 18 | -def complete( | |
| 19 | - input_path: str, | |
| 20 | - output_dir: str = None, | |
| 21 | - cms_name: str = None, | |
| 22 | - cms_logo: str = None, | |
| 23 | - cms_color: str = None, | |
| 24 | -) -> str: | |
| 25 | - """ | |
| 26 | - Run the full pipeline for a theme. | |
| 27 | - | |
| 28 | - Args: | |
| 29 | - input_path: Path to a theme directory (from themes/) or raw HTML dir. | |
| 30 | - output_dir: Where to write the output site. Defaults to output/{theme-name}. | |
| 31 | - cms_name: Whitelabel CMS name for Decap admin UI. | |
| 32 | - cms_logo: Whitelabel logo URL for Decap admin UI. | |
| 33 | - cms_color: Whitelabel top-bar color for Decap admin UI. | |
| 34 | - | |
| 35 | - Returns: | |
| 36 | - Path to the generated site, or error message. | |
| 37 | - """ | |
| 38 | - logging.info(f"Starting pipeline for {input_path} ...") | |
| 39 | - | |
| 40 | - branding = {'cms_name': cms_name, 'cms_logo': cms_logo, 'cms_color': cms_color} | |
| 41 | - info = find_hugo_theme(input_path) | |
| 42 | - | |
| 43 | - if info: | |
| 44 | - return _assemble_hugo_site(info, output_dir, branding) | |
| 45 | - else: | |
| 46 | - # Raw HTML path | |
| 47 | - html_files = find_raw_html_files(input_path) | |
| 48 | - if not html_files: | |
| 49 | - raise ValueError(f"No Hugo theme or HTML files found in {input_path}") | |
| 50 | - return _convert_raw_html(input_path, html_files, output_dir, branding) | |
| 51 | - | |
| 52 | - | |
| 53 | -# --------------------------------------------------------------------------- | |
| 54 | -# Hugo theme path | |
| 55 | -# --------------------------------------------------------------------------- | |
| 56 | - | |
| 57 | -def _assemble_hugo_site(info: dict, output_dir: str = None, branding: dict = None) -> str: | |
| 58 | - theme_dir = info['theme_dir'] | |
| 59 | - example_site = info['example_site'] | |
| 60 | - theme_name = info['theme_name'] | |
| 61 | - | |
| 62 | - if output_dir is None: | |
| 63 | - output_dir = str(Path(__file__).parents[2] / 'output' / theme_name) | |
| 64 | - | |
| 65 | - logging.info(f"Building site at {output_dir} ...") | |
| 66 | - os.makedirs(output_dir, exist_ok=True) | |
| 67 | - | |
| 68 | - # 1. Copy theme files → themes/{theme_name}/ | |
| 69 | - dest_theme = os.path.join(output_dir, 'themes', theme_name) | |
| 70 | - _copy_dir(theme_dir, dest_theme, exclude={'exampleSite', '__MACOSX', '.DS_Store'}) | |
| 71 | - logging.info(f"Copied theme to {dest_theme}") | |
| 72 | - patch_theme(dest_theme) | |
| 73 | - | |
| 74 | - # 2. Copy exampleSite content/static/data → site root | |
| 75 | - if example_site: | |
| 76 | - for subdir in ('content', 'data', 'i18n'): | |
| 77 | - src = os.path.join(example_site, subdir) | |
| 78 | - if os.path.isdir(src): | |
| 79 | - _copy_dir(src, os.path.join(output_dir, subdir)) | |
| 80 | - logging.info(f"Copied {subdir}/ from exampleSite") | |
| 81 | - | |
| 82 | - # Static: merge exampleSite/static into output/static | |
| 83 | - src_static = os.path.join(example_site, 'static') | |
| 84 | - if os.path.isdir(src_static): | |
| 85 | - _copy_dir(src_static, os.path.join(output_dir, 'static')) | |
| 86 | - logging.info("Copied static/ from exampleSite") | |
| 87 | - | |
| 88 | - # Write hugo.toml from exampleSite config | |
| 89 | - config_toml = _find_config(example_site) | |
| 90 | - if config_toml: | |
| 91 | - _write_hugo_toml(config_toml, output_dir, theme_name) | |
| 92 | - else: | |
| 93 | - _write_minimal_hugo_toml(output_dir, theme_name) | |
| 94 | - else: | |
| 95 | - _write_minimal_hugo_toml(output_dir, theme_name) | |
| 96 | - # Create minimal content/_index.md | |
| 97 | - content_dir = os.path.join(output_dir, 'content') | |
| 98 | - os.makedirs(content_dir, exist_ok=True) | |
| 99 | - index_md = os.path.join(content_dir, '_index.md') | |
| 100 | - if not os.path.exists(index_md): | |
| 101 | - with open(index_md, 'w') as f: | |
| 102 | - f.write('---\ntitle: Home\n---\n') | |
| 103 | - | |
| 104 | - # 3. Generate Decap CMS config | |
| 105 | - b = branding or {} | |
| 106 | - decapify(output_dir, cms_name=b.get('cms_name'), cms_logo=b.get('cms_logo'), cms_color=b.get('cms_color')) | |
| 107 | - | |
| 108 | - logging.info(f"Done. Site ready at: {output_dir}") | |
| 109 | - logging.info(f"Run: cd {output_dir} && hugo serve") | |
| 110 | - return output_dir | |
| 111 | - | |
| 112 | - | |
| 113 | -# --------------------------------------------------------------------------- | |
| 114 | -# Raw HTML path | |
| 115 | -# --------------------------------------------------------------------------- | |
| 116 | - | |
| 117 | -def _convert_raw_html(input_path: str, html_files: list, output_dir: str = None, branding: dict = None) -> str: | |
| 118 | - theme_name = os.path.basename(os.path.abspath(input_path)) | |
| 119 | - | |
| 120 | - if output_dir is None: | |
| 121 | - output_dir = str(Path(__file__).parents[2] / 'output' / theme_name) | |
| 122 | - | |
| 123 | - logging.info(f"Converting raw HTML theme: {theme_name}") | |
| 124 | - | |
| 125 | - # Use AI to convert the main HTML file to Hugo layouts | |
| 126 | - main_html = _pick_main_html(html_files) | |
| 127 | - logging.info(f"Converting {main_html} ...") | |
| 128 | - hugo_layouts = hugoify_html(main_html) | |
| 129 | - | |
| 130 | - os.makedirs(output_dir, exist_ok=True) | |
| 131 | - | |
| 132 | - # Write converted layouts | |
| 133 | - theme_layouts_dir = os.path.join(output_dir, 'themes', theme_name, 'layouts') | |
| 134 | - os.makedirs(os.path.join(theme_layouts_dir, '_default'), exist_ok=True) | |
| 135 | - os.makedirs(os.path.join(theme_layouts_dir, 'partials'), exist_ok=True) | |
| 136 | - | |
| 137 | - for filename, content in hugo_layouts.items(): | |
| 138 | - dest = os.path.join(theme_layouts_dir, filename) | |
| 139 | - os.makedirs(os.path.dirname(dest), exist_ok=True) | |
| 140 | - with open(dest, 'w') as f: | |
| 141 | - f.write(content) | |
| 142 | - | |
| 143 | - # Copy CSS/JS/images | |
| 144 | - for ext_dir in ('css', 'js', 'images', 'img', 'assets', 'fonts'): | |
| 145 | - src = os.path.join(input_path, ext_dir) | |
| 146 | - if os.path.isdir(src): | |
| 147 | - _copy_dir(src, os.path.join(output_dir, 'themes', theme_name, 'static', ext_dir)) | |
| 148 | - | |
| 149 | - _write_minimal_hugo_toml(output_dir, theme_name) | |
| 150 | - | |
| 151 | - # Create minimal content | |
| 152 | - content_dir = os.path.join(output_dir, 'content') | |
| 153 | - os.makedirs(content_dir, exist_ok=True) | |
| 154 | - with open(os.path.join(content_dir, '_index.md'), 'w') as f: | |
| 155 | - f.write('---\ntitle: Home\n---\n') | |
| 156 | - | |
| 157 | - b = branding or {} | |
| 158 | - decapify(output_dir, cms_name=b.get('cms_name'), cms_logo=b.get('cms_logo'), cms_color=b.get('cms_color')) | |
| 159 | - | |
| 160 | - logging.info(f"Done. Site ready at: {output_dir}") | |
| 161 | - return output_dir | |
| 162 | - | |
| 163 | - | |
| 164 | -# --------------------------------------------------------------------------- | |
| 165 | -# Helpers | |
| 166 | -# --------------------------------------------------------------------------- | |
| 167 | - | |
| 168 | -def _copy_dir(src: str, dest: str, exclude: set = None): | |
| 169 | - """Copy src → dest, skipping excluded names.""" | |
| 170 | - exclude = exclude or set() | |
| 171 | - if not os.path.isdir(src): | |
| 172 | - return | |
| 173 | - os.makedirs(dest, exist_ok=True) | |
| 174 | - for item in os.listdir(src): | |
| 175 | - if item in exclude or item.startswith('._'): | |
| 176 | - continue | |
| 177 | - s = os.path.join(src, item) | |
| 178 | - d = os.path.join(dest, item) | |
| 179 | - if os.path.isdir(s): | |
| 180 | - _copy_dir(s, d, exclude) | |
| 181 | - else: | |
| 182 | - shutil.copy2(s, d) | |
| 183 | - | |
| 184 | - | |
| 185 | -def _find_config(example_site: str) -> str | None: | |
| 186 | - """Find config.toml or hugo.toml in exampleSite.""" | |
| 187 | - for name in ('hugo.toml', 'config.toml'): | |
| 188 | - p = os.path.join(example_site, name) | |
| 189 | - if os.path.exists(p): | |
| 190 | - return p | |
| 191 | - # config/_default/config.toml pattern | |
| 192 | - p = os.path.join(example_site, 'config', '_default', 'config.toml') | |
| 193 | - if os.path.exists(p): | |
| 194 | - return p | |
| 195 | - return None | |
| 196 | - | |
| 197 | - | |
| 198 | -def _write_hugo_toml(source_config: str, output_dir: str, theme_name: str): | |
| 199 | - """Copy source config to hugo.toml, ensuring theme = theme_name and modern key names.""" | |
| 200 | - import re | |
| 201 | - with open(source_config, 'r') as f: | |
| 202 | - content = f.read() | |
| 203 | - | |
| 204 | - # Suppress noisy but harmless warnings from example content | |
| 205 | - if 'ignoreLogs' not in content: | |
| 206 | - content += "\nignorelogs = ['warning-goldmark-raw-html']\n" | |
| 207 | - | |
| 208 | - # Ensure theme is set correctly | |
| 209 | - if re.search(r'^theme\s*=', content, re.MULTILINE): | |
| 210 | - content = re.sub(r'^theme\s*=.*$', f'theme = "{theme_name}"', content, flags=re.MULTILINE) | |
| 211 | - else: | |
| 212 | - content = f'theme = "{theme_name}"\n' + content | |
| 213 | - | |
| 214 | - dest = os.path.join(output_dir, 'hugo.toml') | |
| 215 | - with open(dest, 'w') as f: | |
| 216 | - f.write(content) | |
| 217 | - patch_config(dest) | |
| 218 | - logging.info("Wrote hugo.toml") | |
| 219 | - | |
| 220 | - | |
| 221 | -def _write_minimal_hugo_toml(output_dir: str, theme_name: str): | |
| 222 | - dest = os.path.join(output_dir, 'hugo.toml') | |
| 223 | - safe_name = theme_name.replace('"', '') | |
| 224 | - title = safe_name.replace('-', ' ').title() | |
| 225 | - with open(dest, 'w') as f: | |
| 226 | - f.write(f'''baseURL = "http://localhost:1313/" | |
| 227 | -languageCode = "en-us" | |
| 228 | -title = "{title}" | |
| 229 | -theme = "{safe_name}" | |
| 230 | -''') | |
| 231 | - logging.info("Wrote minimal hugo.toml") | |
| 232 | - | |
| 233 | - | |
| 234 | -def _pick_main_html(html_files: list) -> str: | |
| 235 | - """Pick the most likely 'main' HTML file (index.html or first one).""" | |
| 236 | - for f in html_files: | |
| 237 | - if os.path.basename(f).lower() in ('index.html', 'home.html', 'main.html'): | |
| 238 | - return f | |
| 239 | - return html_files[0] |
| --- a/src/utils/complete.py | |
| +++ b/src/utils/complete.py | |
| @@ -1,239 +0,0 @@ | |
| 1 | """ |
| 2 | Full end-to-end pipeline: detect → copy → configure → decap. |
| 3 | |
| 4 | For already-Hugo themes: assembles a clean, standalone site. |
| 5 | For raw HTML themes: calls hugoify first, then assembles. |
| 6 | """ |
| 7 | |
| 8 | import logging |
| 9 | import os |
| 10 | import shutil |
| 11 | from pathlib import Path |
| 12 | |
| 13 | from utils.theme_finder import find_hugo_theme, find_raw_html_files |
| 14 | from utils.hugoify import hugoify_html |
| 15 | from utils.decapify import decapify |
| 16 | from utils.theme_patcher import patch_theme, patch_config |
| 17 | |
| 18 | def complete( |
| 19 | input_path: str, |
| 20 | output_dir: str = None, |
| 21 | cms_name: str = None, |
| 22 | cms_logo: str = None, |
| 23 | cms_color: str = None, |
| 24 | ) -> str: |
| 25 | """ |
| 26 | Run the full pipeline for a theme. |
| 27 | |
| 28 | Args: |
| 29 | input_path: Path to a theme directory (from themes/) or raw HTML dir. |
| 30 | output_dir: Where to write the output site. Defaults to output/{theme-name}. |
| 31 | cms_name: Whitelabel CMS name for Decap admin UI. |
| 32 | cms_logo: Whitelabel logo URL for Decap admin UI. |
| 33 | cms_color: Whitelabel top-bar color for Decap admin UI. |
| 34 | |
| 35 | Returns: |
| 36 | Path to the generated site, or error message. |
| 37 | """ |
| 38 | logging.info(f"Starting pipeline for {input_path} ...") |
| 39 | |
| 40 | branding = {'cms_name': cms_name, 'cms_logo': cms_logo, 'cms_color': cms_color} |
| 41 | info = find_hugo_theme(input_path) |
| 42 | |
| 43 | if info: |
| 44 | return _assemble_hugo_site(info, output_dir, branding) |
| 45 | else: |
| 46 | # Raw HTML path |
| 47 | html_files = find_raw_html_files(input_path) |
| 48 | if not html_files: |
| 49 | raise ValueError(f"No Hugo theme or HTML files found in {input_path}") |
| 50 | return _convert_raw_html(input_path, html_files, output_dir, branding) |
| 51 | |
| 52 | |
| 53 | # --------------------------------------------------------------------------- |
| 54 | # Hugo theme path |
| 55 | # --------------------------------------------------------------------------- |
| 56 | |
| 57 | def _assemble_hugo_site(info: dict, output_dir: str = None, branding: dict = None) -> str: |
| 58 | theme_dir = info['theme_dir'] |
| 59 | example_site = info['example_site'] |
| 60 | theme_name = info['theme_name'] |
| 61 | |
| 62 | if output_dir is None: |
| 63 | output_dir = str(Path(__file__).parents[2] / 'output' / theme_name) |
| 64 | |
| 65 | logging.info(f"Building site at {output_dir} ...") |
| 66 | os.makedirs(output_dir, exist_ok=True) |
| 67 | |
| 68 | # 1. Copy theme files → themes/{theme_name}/ |
| 69 | dest_theme = os.path.join(output_dir, 'themes', theme_name) |
| 70 | _copy_dir(theme_dir, dest_theme, exclude={'exampleSite', '__MACOSX', '.DS_Store'}) |
| 71 | logging.info(f"Copied theme to {dest_theme}") |
| 72 | patch_theme(dest_theme) |
| 73 | |
| 74 | # 2. Copy exampleSite content/static/data → site root |
| 75 | if example_site: |
| 76 | for subdir in ('content', 'data', 'i18n'): |
| 77 | src = os.path.join(example_site, subdir) |
| 78 | if os.path.isdir(src): |
| 79 | _copy_dir(src, os.path.join(output_dir, subdir)) |
| 80 | logging.info(f"Copied {subdir}/ from exampleSite") |
| 81 | |
| 82 | # Static: merge exampleSite/static into output/static |
| 83 | src_static = os.path.join(example_site, 'static') |
| 84 | if os.path.isdir(src_static): |
| 85 | _copy_dir(src_static, os.path.join(output_dir, 'static')) |
| 86 | logging.info("Copied static/ from exampleSite") |
| 87 | |
| 88 | # Write hugo.toml from exampleSite config |
| 89 | config_toml = _find_config(example_site) |
| 90 | if config_toml: |
| 91 | _write_hugo_toml(config_toml, output_dir, theme_name) |
| 92 | else: |
| 93 | _write_minimal_hugo_toml(output_dir, theme_name) |
| 94 | else: |
| 95 | _write_minimal_hugo_toml(output_dir, theme_name) |
| 96 | # Create minimal content/_index.md |
| 97 | content_dir = os.path.join(output_dir, 'content') |
| 98 | os.makedirs(content_dir, exist_ok=True) |
| 99 | index_md = os.path.join(content_dir, '_index.md') |
| 100 | if not os.path.exists(index_md): |
| 101 | with open(index_md, 'w') as f: |
| 102 | f.write('---\ntitle: Home\n---\n') |
| 103 | |
| 104 | # 3. Generate Decap CMS config |
| 105 | b = branding or {} |
| 106 | decapify(output_dir, cms_name=b.get('cms_name'), cms_logo=b.get('cms_logo'), cms_color=b.get('cms_color')) |
| 107 | |
| 108 | logging.info(f"Done. Site ready at: {output_dir}") |
| 109 | logging.info(f"Run: cd {output_dir} && hugo serve") |
| 110 | return output_dir |
| 111 | |
| 112 | |
| 113 | # --------------------------------------------------------------------------- |
| 114 | # Raw HTML path |
| 115 | # --------------------------------------------------------------------------- |
| 116 | |
| 117 | def _convert_raw_html(input_path: str, html_files: list, output_dir: str = None, branding: dict = None) -> str: |
| 118 | theme_name = os.path.basename(os.path.abspath(input_path)) |
| 119 | |
| 120 | if output_dir is None: |
| 121 | output_dir = str(Path(__file__).parents[2] / 'output' / theme_name) |
| 122 | |
| 123 | logging.info(f"Converting raw HTML theme: {theme_name}") |
| 124 | |
| 125 | # Use AI to convert the main HTML file to Hugo layouts |
| 126 | main_html = _pick_main_html(html_files) |
| 127 | logging.info(f"Converting {main_html} ...") |
| 128 | hugo_layouts = hugoify_html(main_html) |
| 129 | |
| 130 | os.makedirs(output_dir, exist_ok=True) |
| 131 | |
| 132 | # Write converted layouts |
| 133 | theme_layouts_dir = os.path.join(output_dir, 'themes', theme_name, 'layouts') |
| 134 | os.makedirs(os.path.join(theme_layouts_dir, '_default'), exist_ok=True) |
| 135 | os.makedirs(os.path.join(theme_layouts_dir, 'partials'), exist_ok=True) |
| 136 | |
| 137 | for filename, content in hugo_layouts.items(): |
| 138 | dest = os.path.join(theme_layouts_dir, filename) |
| 139 | os.makedirs(os.path.dirname(dest), exist_ok=True) |
| 140 | with open(dest, 'w') as f: |
| 141 | f.write(content) |
| 142 | |
| 143 | # Copy CSS/JS/images |
| 144 | for ext_dir in ('css', 'js', 'images', 'img', 'assets', 'fonts'): |
| 145 | src = os.path.join(input_path, ext_dir) |
| 146 | if os.path.isdir(src): |
| 147 | _copy_dir(src, os.path.join(output_dir, 'themes', theme_name, 'static', ext_dir)) |
| 148 | |
| 149 | _write_minimal_hugo_toml(output_dir, theme_name) |
| 150 | |
| 151 | # Create minimal content |
| 152 | content_dir = os.path.join(output_dir, 'content') |
| 153 | os.makedirs(content_dir, exist_ok=True) |
| 154 | with open(os.path.join(content_dir, '_index.md'), 'w') as f: |
| 155 | f.write('---\ntitle: Home\n---\n') |
| 156 | |
| 157 | b = branding or {} |
| 158 | decapify(output_dir, cms_name=b.get('cms_name'), cms_logo=b.get('cms_logo'), cms_color=b.get('cms_color')) |
| 159 | |
| 160 | logging.info(f"Done. Site ready at: {output_dir}") |
| 161 | return output_dir |
| 162 | |
| 163 | |
| 164 | # --------------------------------------------------------------------------- |
| 165 | # Helpers |
| 166 | # --------------------------------------------------------------------------- |
| 167 | |
| 168 | def _copy_dir(src: str, dest: str, exclude: set = None): |
| 169 | """Copy src → dest, skipping excluded names.""" |
| 170 | exclude = exclude or set() |
| 171 | if not os.path.isdir(src): |
| 172 | return |
| 173 | os.makedirs(dest, exist_ok=True) |
| 174 | for item in os.listdir(src): |
| 175 | if item in exclude or item.startswith('._'): |
| 176 | continue |
| 177 | s = os.path.join(src, item) |
| 178 | d = os.path.join(dest, item) |
| 179 | if os.path.isdir(s): |
| 180 | _copy_dir(s, d, exclude) |
| 181 | else: |
| 182 | shutil.copy2(s, d) |
| 183 | |
| 184 | |
| 185 | def _find_config(example_site: str) -> str | None: |
| 186 | """Find config.toml or hugo.toml in exampleSite.""" |
| 187 | for name in ('hugo.toml', 'config.toml'): |
| 188 | p = os.path.join(example_site, name) |
| 189 | if os.path.exists(p): |
| 190 | return p |
| 191 | # config/_default/config.toml pattern |
| 192 | p = os.path.join(example_site, 'config', '_default', 'config.toml') |
| 193 | if os.path.exists(p): |
| 194 | return p |
| 195 | return None |
| 196 | |
| 197 | |
| 198 | def _write_hugo_toml(source_config: str, output_dir: str, theme_name: str): |
| 199 | """Copy source config to hugo.toml, ensuring theme = theme_name and modern key names.""" |
| 200 | import re |
| 201 | with open(source_config, 'r') as f: |
| 202 | content = f.read() |
| 203 | |
| 204 | # Suppress noisy but harmless warnings from example content |
| 205 | if 'ignoreLogs' not in content: |
| 206 | content += "\nignorelogs = ['warning-goldmark-raw-html']\n" |
| 207 | |
| 208 | # Ensure theme is set correctly |
| 209 | if re.search(r'^theme\s*=', content, re.MULTILINE): |
| 210 | content = re.sub(r'^theme\s*=.*$', f'theme = "{theme_name}"', content, flags=re.MULTILINE) |
| 211 | else: |
| 212 | content = f'theme = "{theme_name}"\n' + content |
| 213 | |
| 214 | dest = os.path.join(output_dir, 'hugo.toml') |
| 215 | with open(dest, 'w') as f: |
| 216 | f.write(content) |
| 217 | patch_config(dest) |
| 218 | logging.info("Wrote hugo.toml") |
| 219 | |
| 220 | |
| 221 | def _write_minimal_hugo_toml(output_dir: str, theme_name: str): |
| 222 | dest = os.path.join(output_dir, 'hugo.toml') |
| 223 | safe_name = theme_name.replace('"', '') |
| 224 | title = safe_name.replace('-', ' ').title() |
| 225 | with open(dest, 'w') as f: |
| 226 | f.write(f'''baseURL = "http://localhost:1313/" |
| 227 | languageCode = "en-us" |
| 228 | title = "{title}" |
| 229 | theme = "{safe_name}" |
| 230 | ''') |
| 231 | logging.info("Wrote minimal hugo.toml") |
| 232 | |
| 233 | |
| 234 | def _pick_main_html(html_files: list) -> str: |
| 235 | """Pick the most likely 'main' HTML file (index.html or first one).""" |
| 236 | for f in html_files: |
| 237 | if os.path.basename(f).lower() in ('index.html', 'home.html', 'main.html'): |
| 238 | return f |
| 239 | return html_files[0] |
| --- a/src/utils/complete.py | |
| +++ b/src/utils/complete.py | |
| @@ -1,239 +0,0 @@ | |
D
src/utils/decapify.py
-270
| --- a/src/utils/decapify.py | ||
| +++ b/src/utils/decapify.py | ||
| @@ -1,270 +0,0 @@ | ||
| 1 | -""" | |
| 2 | -Generates Decap CMS integration for a Hugo site. | |
| 3 | - | |
| 4 | -Writes: | |
| 5 | - static/admin/index.html — Decap CMS admin panel | |
| 6 | - static/admin/config.yml — CMS config mapped to actual content structure | |
| 7 | -""" | |
| 8 | - | |
| 9 | -import logging | |
| 10 | -import os | |
| 11 | -import re | |
| 12 | -import yaml | |
| 13 | - | |
| 14 | -DECAP_CDN = "https://unpkg.com/decap-cms@^3.0.0/dist/decap-cms.js" | |
| 15 | - | |
| 16 | -# Whitelabel defaults — override via decapify() kwargs or env vars | |
| 17 | -DEFAULT_CMS_NAME = os.getenv('CMS_NAME', 'Content Manager') | |
| 18 | -DEFAULT_CMS_LOGO = os.getenv('CMS_LOGO_URL', '') # URL or empty | |
| 19 | -DEFAULT_CMS_COLOR = os.getenv('CMS_COLOR', '#2e3748') # top-bar background | |
| 20 | - | |
| 21 | - | |
| 22 | -def decapify( | |
| 23 | - site_dir: str, | |
| 24 | - cms_name: str = None, | |
| 25 | - cms_logo: str = None, | |
| 26 | - cms_color: str = None, | |
| 27 | -) -> str: | |
| 28 | - """ | |
| 29 | - Add Decap CMS to a Hugo site directory. | |
| 30 | - | |
| 31 | - Args: | |
| 32 | - site_dir: Root of the assembled Hugo site (has hugo.toml, content/, themes/). | |
| 33 | - cms_name: Whitelabel name shown in the admin UI (default: 'Content Manager'). | |
| 34 | - cms_logo: URL to a logo image for the admin UI (optional). | |
| 35 | - cms_color: Hex color for the admin top bar (default: '#2e3748'). | |
| 36 | - | |
| 37 | - Returns: | |
| 38 | - Status message. | |
| 39 | - """ | |
| 40 | - logging.info(f"Adding Decap CMS to {site_dir} ...") | |
| 41 | - | |
| 42 | - admin_dir = os.path.join(site_dir, 'static', 'admin') | |
| 43 | - os.makedirs(admin_dir, exist_ok=True) | |
| 44 | - | |
| 45 | - branding = { | |
| 46 | - 'name': cms_name or DEFAULT_CMS_NAME, | |
| 47 | - 'logo': cms_logo or DEFAULT_CMS_LOGO, | |
| 48 | - 'color': cms_color or DEFAULT_CMS_COLOR, | |
| 49 | - } | |
| 50 | - | |
| 51 | - _write_admin_index(admin_dir, branding) | |
| 52 | - _write_decap_config(site_dir, admin_dir) | |
| 53 | - | |
| 54 | - logging.info("Decap CMS integration complete.") | |
| 55 | - return "Decap CMS integration complete" | |
| 56 | - | |
| 57 | - | |
| 58 | -# --------------------------------------------------------------------------- | |
| 59 | -# Admin index.html | |
| 60 | -# --------------------------------------------------------------------------- | |
| 61 | - | |
| 62 | -def _sanitize_color(color: str) -> str: | |
| 63 | - """Allow only valid CSS hex colors (#rgb or #rrggbb) to prevent style injection.""" | |
| 64 | - if re.fullmatch(r'#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?', color): | |
| 65 | - return color | |
| 66 | - return '#2e3748' # fall back to default | |
| 67 | - | |
| 68 | - | |
| 69 | -def _write_admin_index(admin_dir: str, branding: dict): | |
| 70 | - import html as html_mod | |
| 71 | - name = html_mod.escape(branding['name']) | |
| 72 | - logo_html = '' | |
| 73 | - if branding['logo']: | |
| 74 | - logo_url = html_mod.escape(branding['logo']) | |
| 75 | - logo_html = f'\n <img src="{logo_url}" alt="{name}" style="max-height:40px;margin:8px 0;">' | |
| 76 | - | |
| 77 | - color_css = '' | |
| 78 | - if branding['color']: | |
| 79 | - safe_color = _sanitize_color(branding['color']) | |
| 80 | - color_css = f""" | |
| 81 | - <style> | |
| 82 | - [class^="AppHeader"] {{ background-color: {safe_color} !important; }} | |
| 83 | - </style>""" | |
| 84 | - | |
| 85 | - html = f"""<!doctype html> | |
| 86 | -<html> | |
| 87 | -<head> | |
| 88 | - <meta charset="utf-8" /> | |
| 89 | - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 90 | - <meta name="robots" content="noindex" /> | |
| 91 | - <title>{name}</title>{color_css} | |
| 92 | -</head> | |
| 93 | -<body>{logo_html} | |
| 94 | - <script src="{DECAP_CDN}"></script> | |
| 95 | -</body> | |
| 96 | -</html> | |
| 97 | -""" | |
| 98 | - with open(os.path.join(admin_dir, 'index.html'), 'w') as f: | |
| 99 | - f.write(html) | |
| 100 | - | |
| 101 | - | |
| 102 | -# --------------------------------------------------------------------------- | |
| 103 | -# config.yml | |
| 104 | -# --------------------------------------------------------------------------- | |
| 105 | - | |
| 106 | -def _write_decap_config(site_dir: str, admin_dir: str): | |
| 107 | - content_dir = os.path.join(site_dir, 'content') | |
| 108 | - collections = _build_collections(content_dir) | |
| 109 | - | |
| 110 | - config = { | |
| 111 | - 'backend': { | |
| 112 | - 'name': 'git-gateway', | |
| 113 | - 'branch': 'main', | |
| 114 | - }, | |
| 115 | - 'media_folder': 'static/images/uploads', | |
| 116 | - 'public_folder': '/images/uploads', | |
| 117 | - 'collections': collections, | |
| 118 | - } | |
| 119 | - | |
| 120 | - config_path = os.path.join(admin_dir, 'config.yml') | |
| 121 | - with open(config_path, 'w') as f: | |
| 122 | - yaml.dump(config, f, default_flow_style=False, allow_unicode=True, sort_keys=False) | |
| 123 | - | |
| 124 | - logging.info(f"Wrote Decap CMS config to {config_path}") | |
| 125 | - | |
| 126 | - | |
| 127 | -def _collect_md_files(dirpath: str) -> list: | |
| 128 | - """Recursively collect all .md files under dirpath (excluding _index.md).""" | |
| 129 | - found = [] | |
| 130 | - for root, dirs, files in os.walk(dirpath): | |
| 131 | - for f in files: | |
| 132 | - if f.endswith('.md') and f != '_index.md': | |
| 133 | - found.append(os.path.join(root, f)) | |
| 134 | - return found | |
| 135 | - | |
| 136 | - | |
| 137 | -def _build_collections(content_dir: str) -> list: | |
| 138 | - """ | |
| 139 | - Inspect content/ to build Decap CMS collections. | |
| 140 | - - Subdirs with any .md files (at any depth) → folder collection (e.g. blog) | |
| 141 | - - Subdirs with only a top-level _index.md → file collection (e.g. about, contact) | |
| 142 | - """ | |
| 143 | - if not os.path.isdir(content_dir): | |
| 144 | - return [_default_pages_collection()] | |
| 145 | - | |
| 146 | - collections = [] | |
| 147 | - | |
| 148 | - entries = sorted(os.listdir(content_dir)) | |
| 149 | - for entry in entries: | |
| 150 | - subdir = os.path.join(content_dir, entry) | |
| 151 | - if not os.path.isdir(subdir): | |
| 152 | - continue | |
| 153 | - | |
| 154 | - # Collect all .md files at any depth (excluding _index.md) | |
| 155 | - non_index = _collect_md_files(subdir) | |
| 156 | - has_index = os.path.exists(os.path.join(subdir, '_index.md')) | |
| 157 | - | |
| 158 | - if non_index: | |
| 159 | - # Folder collection (blog, posts, etc.) — use shallowest sample for field inference | |
| 160 | - fields = _infer_fields_for_folder(subdir, [os.path.relpath(f, subdir) for f in non_index]) | |
| 161 | - collections.append({ | |
| 162 | - 'name': entry, | |
| 163 | - 'label': entry.replace('-', ' ').title(), | |
| 164 | - 'folder': f'content/{entry}', | |
| 165 | - 'create': True, | |
| 166 | - 'slug': '{{slug}}', | |
| 167 | - 'fields': fields, | |
| 168 | - }) | |
| 169 | - elif has_index: | |
| 170 | - # File collection (single page) | |
| 171 | - fields = _infer_fields_for_file(os.path.join(subdir, '_index.md')) | |
| 172 | - collections.append({ | |
| 173 | - 'name': entry, | |
| 174 | - 'label': entry.replace('-', ' ').title(), | |
| 175 | - 'files': [{ | |
| 176 | - 'name': entry, | |
| 177 | - 'label': entry.replace('-', ' ').title(), | |
| 178 | - 'file': f'content/{entry}/_index.md', | |
| 179 | - 'fields': fields, | |
| 180 | - }], | |
| 181 | - }) | |
| 182 | - | |
| 183 | - if not collections: | |
| 184 | - collections.append(_default_pages_collection()) | |
| 185 | - | |
| 186 | - return collections | |
| 187 | - | |
| 188 | - | |
| 189 | -def _infer_fields_for_folder(subdir: str, md_files: list) -> list: | |
| 190 | - """Read a sample .md file and extract frontmatter keys as fields.""" | |
| 191 | - # md_files may be relative paths (from _collect_md_files); resolve to absolute | |
| 192 | - first = md_files[0] | |
| 193 | - sample = first if os.path.isabs(first) else os.path.join(subdir, first) | |
| 194 | - frontmatter = _parse_frontmatter(sample) | |
| 195 | - | |
| 196 | - fields = [] | |
| 197 | - field_map = { | |
| 198 | - 'title': {'label': 'Title', 'name': 'title', 'widget': 'string'}, | |
| 199 | - 'date': {'label': 'Date', 'name': 'date', 'widget': 'datetime'}, | |
| 200 | - 'description': {'label': 'Description', 'name': 'description', 'widget': 'text'}, | |
| 201 | - 'image': {'label': 'Image', 'name': 'image', 'widget': 'image', 'required': False}, | |
| 202 | - 'categories': {'label': 'Categories', 'name': 'categories', 'widget': 'list', 'required': False}, | |
| 203 | - 'tags': {'label': 'Tags', 'name': 'tags', 'widget': 'list', 'required': False}, | |
| 204 | - 'draft': {'label': 'Draft', 'name': 'draft', 'widget': 'boolean', 'default': False}, | |
| 205 | - 'author': {'label': 'Author', 'name': 'author', 'widget': 'string', 'required': False}, | |
| 206 | - } | |
| 207 | - | |
| 208 | - # Add known fields in a logical order | |
| 209 | - for key in ['title', 'date', 'description', 'image', 'categories', 'tags', 'author', 'draft']: | |
| 210 | - if key in frontmatter: | |
| 211 | - fields.append(field_map[key]) | |
| 212 | - | |
| 213 | - # Add any remaining frontmatter keys not in our map | |
| 214 | - for key, value in frontmatter.items(): | |
| 215 | - if key not in field_map and key not in ('type', 'layout', 'url'): | |
| 216 | - widget = _widget_for_value(value) | |
| 217 | - fields.append({'label': key.title(), 'name': key, 'widget': widget, 'required': False}) | |
| 218 | - | |
| 219 | - # Always include body | |
| 220 | - fields.append({'label': 'Body', 'name': 'body', 'widget': 'markdown'}) | |
| 221 | - | |
| 222 | - return fields | |
| 223 | - | |
| 224 | - | |
| 225 | -def _infer_fields_for_file(md_path: str) -> list: | |
| 226 | - """For a single page (_index.md), infer fields from frontmatter.""" | |
| 227 | - frontmatter = _parse_frontmatter(md_path) | |
| 228 | - fields = [] | |
| 229 | - for key, value in frontmatter.items(): | |
| 230 | - widget = _widget_for_value(value) | |
| 231 | - fields.append({'label': key.title(), 'name': key, 'widget': widget, 'required': False}) | |
| 232 | - fields.append({'label': 'Body', 'name': 'body', 'widget': 'markdown'}) | |
| 233 | - return fields | |
| 234 | - | |
| 235 | - | |
| 236 | -def _parse_frontmatter(md_path: str) -> dict: | |
| 237 | - """Parse YAML frontmatter from a .md file.""" | |
| 238 | - try: | |
| 239 | - with open(md_path, 'r', errors='replace') as f: | |
| 240 | - content = f.read() | |
| 241 | - match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) | |
| 242 | - if match: | |
| 243 | - return yaml.safe_load(match.group(1)) or {} | |
| 244 | - except Exception: | |
| 245 | - pass | |
| 246 | - return {} | |
| 247 | - | |
| 248 | - | |
| 249 | -def _widget_for_value(value) -> str: | |
| 250 | - if isinstance(value, bool): | |
| 251 | - return 'boolean' | |
| 252 | - if isinstance(value, (int, float)): | |
| 253 | - return 'number' | |
| 254 | - if isinstance(value, list): | |
| 255 | - return 'list' | |
| 256 | - return 'string' | |
| 257 | - | |
| 258 | - | |
| 259 | -def _default_pages_collection() -> dict: | |
| 260 | - return { | |
| 261 | - 'name': 'pages', | |
| 262 | - 'label': 'Pages', | |
| 263 | - 'folder': 'content', | |
| 264 | - 'create': True, | |
| 265 | - 'slug': '{{slug}}', | |
| 266 | - 'fields': [ | |
| 267 | - {'label': 'Title', 'name': 'title', 'widget': 'string'}, | |
| 268 | - {'label': 'Body', 'name': 'body', 'widget': 'markdown'}, | |
| 269 | - ], | |
| 270 | - } |
| --- a/src/utils/decapify.py | |
| +++ b/src/utils/decapify.py | |
| @@ -1,270 +0,0 @@ | |
| 1 | """ |
| 2 | Generates Decap CMS integration for a Hugo site. |
| 3 | |
| 4 | Writes: |
| 5 | static/admin/index.html — Decap CMS admin panel |
| 6 | static/admin/config.yml — CMS config mapped to actual content structure |
| 7 | """ |
| 8 | |
| 9 | import logging |
| 10 | import os |
| 11 | import re |
| 12 | import yaml |
| 13 | |
| 14 | DECAP_CDN = "https://unpkg.com/decap-cms@^3.0.0/dist/decap-cms.js" |
| 15 | |
| 16 | # Whitelabel defaults — override via decapify() kwargs or env vars |
| 17 | DEFAULT_CMS_NAME = os.getenv('CMS_NAME', 'Content Manager') |
| 18 | DEFAULT_CMS_LOGO = os.getenv('CMS_LOGO_URL', '') # URL or empty |
| 19 | DEFAULT_CMS_COLOR = os.getenv('CMS_COLOR', '#2e3748') # top-bar background |
| 20 | |
| 21 | |
| 22 | def decapify( |
| 23 | site_dir: str, |
| 24 | cms_name: str = None, |
| 25 | cms_logo: str = None, |
| 26 | cms_color: str = None, |
| 27 | ) -> str: |
| 28 | """ |
| 29 | Add Decap CMS to a Hugo site directory. |
| 30 | |
| 31 | Args: |
| 32 | site_dir: Root of the assembled Hugo site (has hugo.toml, content/, themes/). |
| 33 | cms_name: Whitelabel name shown in the admin UI (default: 'Content Manager'). |
| 34 | cms_logo: URL to a logo image for the admin UI (optional). |
| 35 | cms_color: Hex color for the admin top bar (default: '#2e3748'). |
| 36 | |
| 37 | Returns: |
| 38 | Status message. |
| 39 | """ |
| 40 | logging.info(f"Adding Decap CMS to {site_dir} ...") |
| 41 | |
| 42 | admin_dir = os.path.join(site_dir, 'static', 'admin') |
| 43 | os.makedirs(admin_dir, exist_ok=True) |
| 44 | |
| 45 | branding = { |
| 46 | 'name': cms_name or DEFAULT_CMS_NAME, |
| 47 | 'logo': cms_logo or DEFAULT_CMS_LOGO, |
| 48 | 'color': cms_color or DEFAULT_CMS_COLOR, |
| 49 | } |
| 50 | |
| 51 | _write_admin_index(admin_dir, branding) |
| 52 | _write_decap_config(site_dir, admin_dir) |
| 53 | |
| 54 | logging.info("Decap CMS integration complete.") |
| 55 | return "Decap CMS integration complete" |
| 56 | |
| 57 | |
| 58 | # --------------------------------------------------------------------------- |
| 59 | # Admin index.html |
| 60 | # --------------------------------------------------------------------------- |
| 61 | |
| 62 | def _sanitize_color(color: str) -> str: |
| 63 | """Allow only valid CSS hex colors (#rgb or #rrggbb) to prevent style injection.""" |
| 64 | if re.fullmatch(r'#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?', color): |
| 65 | return color |
| 66 | return '#2e3748' # fall back to default |
| 67 | |
| 68 | |
| 69 | def _write_admin_index(admin_dir: str, branding: dict): |
| 70 | import html as html_mod |
| 71 | name = html_mod.escape(branding['name']) |
| 72 | logo_html = '' |
| 73 | if branding['logo']: |
| 74 | logo_url = html_mod.escape(branding['logo']) |
| 75 | logo_html = f'\n <img src="{logo_url}" alt="{name}" style="max-height:40px;margin:8px 0;">' |
| 76 | |
| 77 | color_css = '' |
| 78 | if branding['color']: |
| 79 | safe_color = _sanitize_color(branding['color']) |
| 80 | color_css = f""" |
| 81 | <style> |
| 82 | [class^="AppHeader"] {{ background-color: {safe_color} !important; }} |
| 83 | </style>""" |
| 84 | |
| 85 | html = f"""<!doctype html> |
| 86 | <html> |
| 87 | <head> |
| 88 | <meta charset="utf-8" /> |
| 89 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 90 | <meta name="robots" content="noindex" /> |
| 91 | <title>{name}</title>{color_css} |
| 92 | </head> |
| 93 | <body>{logo_html} |
| 94 | <script src="{DECAP_CDN}"></script> |
| 95 | </body> |
| 96 | </html> |
| 97 | """ |
| 98 | with open(os.path.join(admin_dir, 'index.html'), 'w') as f: |
| 99 | f.write(html) |
| 100 | |
| 101 | |
| 102 | # --------------------------------------------------------------------------- |
| 103 | # config.yml |
| 104 | # --------------------------------------------------------------------------- |
| 105 | |
| 106 | def _write_decap_config(site_dir: str, admin_dir: str): |
| 107 | content_dir = os.path.join(site_dir, 'content') |
| 108 | collections = _build_collections(content_dir) |
| 109 | |
| 110 | config = { |
| 111 | 'backend': { |
| 112 | 'name': 'git-gateway', |
| 113 | 'branch': 'main', |
| 114 | }, |
| 115 | 'media_folder': 'static/images/uploads', |
| 116 | 'public_folder': '/images/uploads', |
| 117 | 'collections': collections, |
| 118 | } |
| 119 | |
| 120 | config_path = os.path.join(admin_dir, 'config.yml') |
| 121 | with open(config_path, 'w') as f: |
| 122 | yaml.dump(config, f, default_flow_style=False, allow_unicode=True, sort_keys=False) |
| 123 | |
| 124 | logging.info(f"Wrote Decap CMS config to {config_path}") |
| 125 | |
| 126 | |
| 127 | def _collect_md_files(dirpath: str) -> list: |
| 128 | """Recursively collect all .md files under dirpath (excluding _index.md).""" |
| 129 | found = [] |
| 130 | for root, dirs, files in os.walk(dirpath): |
| 131 | for f in files: |
| 132 | if f.endswith('.md') and f != '_index.md': |
| 133 | found.append(os.path.join(root, f)) |
| 134 | return found |
| 135 | |
| 136 | |
| 137 | def _build_collections(content_dir: str) -> list: |
| 138 | """ |
| 139 | Inspect content/ to build Decap CMS collections. |
| 140 | - Subdirs with any .md files (at any depth) → folder collection (e.g. blog) |
| 141 | - Subdirs with only a top-level _index.md → file collection (e.g. about, contact) |
| 142 | """ |
| 143 | if not os.path.isdir(content_dir): |
| 144 | return [_default_pages_collection()] |
| 145 | |
| 146 | collections = [] |
| 147 | |
| 148 | entries = sorted(os.listdir(content_dir)) |
| 149 | for entry in entries: |
| 150 | subdir = os.path.join(content_dir, entry) |
| 151 | if not os.path.isdir(subdir): |
| 152 | continue |
| 153 | |
| 154 | # Collect all .md files at any depth (excluding _index.md) |
| 155 | non_index = _collect_md_files(subdir) |
| 156 | has_index = os.path.exists(os.path.join(subdir, '_index.md')) |
| 157 | |
| 158 | if non_index: |
| 159 | # Folder collection (blog, posts, etc.) — use shallowest sample for field inference |
| 160 | fields = _infer_fields_for_folder(subdir, [os.path.relpath(f, subdir) for f in non_index]) |
| 161 | collections.append({ |
| 162 | 'name': entry, |
| 163 | 'label': entry.replace('-', ' ').title(), |
| 164 | 'folder': f'content/{entry}', |
| 165 | 'create': True, |
| 166 | 'slug': '{{slug}}', |
| 167 | 'fields': fields, |
| 168 | }) |
| 169 | elif has_index: |
| 170 | # File collection (single page) |
| 171 | fields = _infer_fields_for_file(os.path.join(subdir, '_index.md')) |
| 172 | collections.append({ |
| 173 | 'name': entry, |
| 174 | 'label': entry.replace('-', ' ').title(), |
| 175 | 'files': [{ |
| 176 | 'name': entry, |
| 177 | 'label': entry.replace('-', ' ').title(), |
| 178 | 'file': f'content/{entry}/_index.md', |
| 179 | 'fields': fields, |
| 180 | }], |
| 181 | }) |
| 182 | |
| 183 | if not collections: |
| 184 | collections.append(_default_pages_collection()) |
| 185 | |
| 186 | return collections |
| 187 | |
| 188 | |
| 189 | def _infer_fields_for_folder(subdir: str, md_files: list) -> list: |
| 190 | """Read a sample .md file and extract frontmatter keys as fields.""" |
| 191 | # md_files may be relative paths (from _collect_md_files); resolve to absolute |
| 192 | first = md_files[0] |
| 193 | sample = first if os.path.isabs(first) else os.path.join(subdir, first) |
| 194 | frontmatter = _parse_frontmatter(sample) |
| 195 | |
| 196 | fields = [] |
| 197 | field_map = { |
| 198 | 'title': {'label': 'Title', 'name': 'title', 'widget': 'string'}, |
| 199 | 'date': {'label': 'Date', 'name': 'date', 'widget': 'datetime'}, |
| 200 | 'description': {'label': 'Description', 'name': 'description', 'widget': 'text'}, |
| 201 | 'image': {'label': 'Image', 'name': 'image', 'widget': 'image', 'required': False}, |
| 202 | 'categories': {'label': 'Categories', 'name': 'categories', 'widget': 'list', 'required': False}, |
| 203 | 'tags': {'label': 'Tags', 'name': 'tags', 'widget': 'list', 'required': False}, |
| 204 | 'draft': {'label': 'Draft', 'name': 'draft', 'widget': 'boolean', 'default': False}, |
| 205 | 'author': {'label': 'Author', 'name': 'author', 'widget': 'string', 'required': False}, |
| 206 | } |
| 207 | |
| 208 | # Add known fields in a logical order |
| 209 | for key in ['title', 'date', 'description', 'image', 'categories', 'tags', 'author', 'draft']: |
| 210 | if key in frontmatter: |
| 211 | fields.append(field_map[key]) |
| 212 | |
| 213 | # Add any remaining frontmatter keys not in our map |
| 214 | for key, value in frontmatter.items(): |
| 215 | if key not in field_map and key not in ('type', 'layout', 'url'): |
| 216 | widget = _widget_for_value(value) |
| 217 | fields.append({'label': key.title(), 'name': key, 'widget': widget, 'required': False}) |
| 218 | |
| 219 | # Always include body |
| 220 | fields.append({'label': 'Body', 'name': 'body', 'widget': 'markdown'}) |
| 221 | |
| 222 | return fields |
| 223 | |
| 224 | |
| 225 | def _infer_fields_for_file(md_path: str) -> list: |
| 226 | """For a single page (_index.md), infer fields from frontmatter.""" |
| 227 | frontmatter = _parse_frontmatter(md_path) |
| 228 | fields = [] |
| 229 | for key, value in frontmatter.items(): |
| 230 | widget = _widget_for_value(value) |
| 231 | fields.append({'label': key.title(), 'name': key, 'widget': widget, 'required': False}) |
| 232 | fields.append({'label': 'Body', 'name': 'body', 'widget': 'markdown'}) |
| 233 | return fields |
| 234 | |
| 235 | |
| 236 | def _parse_frontmatter(md_path: str) -> dict: |
| 237 | """Parse YAML frontmatter from a .md file.""" |
| 238 | try: |
| 239 | with open(md_path, 'r', errors='replace') as f: |
| 240 | content = f.read() |
| 241 | match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) |
| 242 | if match: |
| 243 | return yaml.safe_load(match.group(1)) or {} |
| 244 | except Exception: |
| 245 | pass |
| 246 | return {} |
| 247 | |
| 248 | |
| 249 | def _widget_for_value(value) -> str: |
| 250 | if isinstance(value, bool): |
| 251 | return 'boolean' |
| 252 | if isinstance(value, (int, float)): |
| 253 | return 'number' |
| 254 | if isinstance(value, list): |
| 255 | return 'list' |
| 256 | return 'string' |
| 257 | |
| 258 | |
| 259 | def _default_pages_collection() -> dict: |
| 260 | return { |
| 261 | 'name': 'pages', |
| 262 | 'label': 'Pages', |
| 263 | 'folder': 'content', |
| 264 | 'create': True, |
| 265 | 'slug': '{{slug}}', |
| 266 | 'fields': [ |
| 267 | {'label': 'Title', 'name': 'title', 'widget': 'string'}, |
| 268 | {'label': 'Body', 'name': 'body', 'widget': 'markdown'}, |
| 269 | ], |
| 270 | } |
| --- a/src/utils/decapify.py | |
| +++ b/src/utils/decapify.py | |
| @@ -1,270 +0,0 @@ | |
D
src/utils/deploy.py
-27
| --- a/src/utils/deploy.py | ||
| +++ b/src/utils/deploy.py | ||
| @@ -1,27 +0,0 @@ | ||
| 1 | -""" | |
| 2 | -This script handles the deployment of a Hugo site, ensuring all prerequisites are met and executing the deployment process. | |
| 3 | -It may use Cloudflare functions for deployment. | |
| 4 | -""" | |
| 5 | - | |
| 6 | -import logging | |
| 7 | - | |
| 8 | -# Function to handle deployment tasks | |
| 9 | -def deploy(path, zone): | |
| 10 | - logging.info(f"Starting deployment for {path} to zone {zone}...") | |
| 11 | - try: | |
| 12 | - # Check prerequisites | |
| 13 | - logging.info("Checking prerequisites...") | |
| 14 | - # Example check for necessary files or configurations | |
| 15 | - # if not os.path.exists(os.path.join(path, 'config.toml')): | |
| 16 | - # raise FileNotFoundError("Missing config.toml") | |
| 17 | - | |
| 18 | - # Deploy site using Cloudflare functions | |
| 19 | - logging.info("Deploying site...") | |
| 20 | - # Example API call to deploy the site | |
| 21 | - # cloudflare_api.deploy_site(path, zone) | |
| 22 | - | |
| 23 | - logging.info("Deployment complete.") | |
| 24 | - return "Deployment complete" | |
| 25 | - except Exception as e: | |
| 26 | - logging.error(f"Error during deployment: {e}") | |
| 27 | - return "Deployment failed" |
| --- a/src/utils/deploy.py | |
| +++ b/src/utils/deploy.py | |
| @@ -1,27 +0,0 @@ | |
| 1 | """ |
| 2 | This script handles the deployment of a Hugo site, ensuring all prerequisites are met and executing the deployment process. |
| 3 | It may use Cloudflare functions for deployment. |
| 4 | """ |
| 5 | |
| 6 | import logging |
| 7 | |
| 8 | # Function to handle deployment tasks |
| 9 | def deploy(path, zone): |
| 10 | logging.info(f"Starting deployment for {path} to zone {zone}...") |
| 11 | try: |
| 12 | # Check prerequisites |
| 13 | logging.info("Checking prerequisites...") |
| 14 | # Example check for necessary files or configurations |
| 15 | # if not os.path.exists(os.path.join(path, 'config.toml')): |
| 16 | # raise FileNotFoundError("Missing config.toml") |
| 17 | |
| 18 | # Deploy site using Cloudflare functions |
| 19 | logging.info("Deploying site...") |
| 20 | # Example API call to deploy the site |
| 21 | # cloudflare_api.deploy_site(path, zone) |
| 22 | |
| 23 | logging.info("Deployment complete.") |
| 24 | return "Deployment complete" |
| 25 | except Exception as e: |
| 26 | logging.error(f"Error during deployment: {e}") |
| 27 | return "Deployment failed" |
| --- a/src/utils/deploy.py | |
| +++ b/src/utils/deploy.py | |
| @@ -1,27 +0,0 @@ | |
D
src/utils/generate_decap_config.py
-10
| --- a/src/utils/generate_decap_config.py | ||
| +++ b/src/utils/generate_decap_config.py | ||
| @@ -1,10 +0,0 @@ | ||
| 1 | -""" | |
| 2 | -generate_decap_config — thin wrapper kept for backwards compatibility. | |
| 3 | -The real implementation lives in decapify.py. | |
| 4 | -""" | |
| 5 | - | |
| 6 | -from utils.decapify import decapify | |
| 7 | - | |
| 8 | - | |
| 9 | -def generate_decap_config(theme_path: str) -> str: | |
| 10 | - return decapify(theme_path) |
| --- a/src/utils/generate_decap_config.py | |
| +++ b/src/utils/generate_decap_config.py | |
| @@ -1,10 +0,0 @@ | |
| 1 | """ |
| 2 | generate_decap_config — thin wrapper kept for backwards compatibility. |
| 3 | The real implementation lives in decapify.py. |
| 4 | """ |
| 5 | |
| 6 | from utils.decapify import decapify |
| 7 | |
| 8 | |
| 9 | def generate_decap_config(theme_path: str) -> str: |
| 10 | return decapify(theme_path) |
| --- a/src/utils/generate_decap_config.py | |
| +++ b/src/utils/generate_decap_config.py | |
| @@ -1,10 +0,0 @@ | |
D
src/utils/hugoify.py
-165
| --- a/src/utils/hugoify.py | ||
| +++ b/src/utils/hugoify.py | ||
| @@ -1,165 +0,0 @@ | ||
| 1 | -""" | |
| 2 | -AI-powered HTML → Hugo template conversion. | |
| 3 | - | |
| 4 | -For already-Hugo themes, use hugoify_dir() to validate/augment. | |
| 5 | -For raw HTML, use hugoify_html() to produce Hugo layout files. | |
| 6 | -""" | |
| 7 | - | |
| 8 | -import logging | |
| 9 | -import os | |
| 10 | -import json | |
| 11 | -import re | |
| 12 | - | |
| 13 | -from config import call_ai | |
| 14 | - | |
| 15 | -SYSTEM = """You are an expert Hugo theme developer. Convert HTML templates to valid Hugo Go template files. | |
| 16 | -Output only valid Hugo template syntax — no explanations, no markdown fences.""" | |
| 17 | - | |
| 18 | - | |
| 19 | -def hugoify_html(html_path: str) -> dict: | |
| 20 | - """ | |
| 21 | - Convert a raw HTML file to a set of Hugo layout files. | |
| 22 | - | |
| 23 | - Returns dict mapping relative layout paths to their content, e.g.: | |
| 24 | - { | |
| 25 | - "_default/baseof.html": "<!DOCTYPE html>...", | |
| 26 | - "partials/header.html": "<header>...", | |
| 27 | - "partials/footer.html": "<footer>...", | |
| 28 | - "index.html": "{{ define \"main\" }}...", | |
| 29 | - } | |
| 30 | - """ | |
| 31 | - logging.info(f"Hugoifying {html_path} ...") | |
| 32 | - | |
| 33 | - with open(html_path, 'r', errors='replace') as f: | |
| 34 | - html = f.read() | |
| 35 | - | |
| 36 | - # Truncate very large files to avoid token limits | |
| 37 | - if len(html) > 30000: | |
| 38 | - logging.warning(f"HTML is large ({len(html)} chars), truncating to 30000 for AI analysis") | |
| 39 | - html = html[:30000] | |
| 40 | - | |
| 41 | - prompt = f"""Convert the following HTML file into Hugo layout files. | |
| 42 | - | |
| 43 | -Return a JSON object where keys are relative file paths under layouts/ and values are the Hugo template content. | |
| 44 | - | |
| 45 | -Required keys to produce: | |
| 46 | -- "_default/baseof.html" — base template with blocks for head, header, main, footer | |
| 47 | -- "partials/header.html" — site header/nav extracted as partial | |
| 48 | -- "partials/footer.html" — footer extracted as partial | |
| 49 | -- "index.html" — homepage using {{ define "main" }} ... {{ end }} | |
| 50 | - | |
| 51 | -Rules: | |
| 52 | -- Replace hardcoded page titles with {{ .Title }} | |
| 53 | -- Replace hardcoded site name with {{ .Site.Title }} | |
| 54 | -- Replace hardcoded URLs with {{ .Site.BaseURL }} or {{ .Permalink }} | |
| 55 | -- Replace nav links with {{ range .Site.Menus.main }}<a href="{{ .URL }}">{{ .Name }}</a>{{ end }} | |
| 56 | -- Replace blog post lists with {{ range .Pages }} ... {{ end }} | |
| 57 | -- Replace copyright year with {{ now.Year }} | |
| 58 | -- Keep all CSS classes and HTML structure intact | |
| 59 | -- Use {{ partial "header.html" . }} and {{ partial "footer.html" . }} in baseof.html | |
| 60 | - | |
| 61 | -HTML to convert: | |
| 62 | -{html} | |
| 63 | - | |
| 64 | -Return ONLY a valid JSON object, no explanation.""" | |
| 65 | - | |
| 66 | - response = call_ai(prompt, SYSTEM) | |
| 67 | - return _parse_layout_json(response) | |
| 68 | - | |
| 69 | - | |
| 70 | -def hugoify_dir(theme_dir: str) -> str: | |
| 71 | - """ | |
| 72 | - Validate and optionally augment an existing Hugo theme directory. | |
| 73 | - Returns a status message. | |
| 74 | - """ | |
| 75 | - logging.info(f"Validating Hugo theme at {theme_dir} ...") | |
| 76 | - | |
| 77 | - issues = [] | |
| 78 | - layouts_dir = os.path.join(theme_dir, 'layouts') | |
| 79 | - | |
| 80 | - if not os.path.isdir(layouts_dir): | |
| 81 | - issues.append("Missing layouts/ directory") | |
| 82 | - return f"Validation failed: {'; '.join(issues)}" | |
| 83 | - | |
| 84 | - required = [ | |
| 85 | - os.path.join(layouts_dir, '_default', 'baseof.html'), | |
| 86 | - ] | |
| 87 | - for f in required: | |
| 88 | - if not os.path.exists(f): | |
| 89 | - issues.append(f"Missing {os.path.relpath(f, theme_dir)}") | |
| 90 | - | |
| 91 | - if issues: | |
| 92 | - logging.warning(f"Issues found: {issues}") | |
| 93 | - return f"Issues: {'; '.join(issues)}" | |
| 94 | - | |
| 95 | - logging.info("Hugo theme validation passed.") | |
| 96 | - return "Valid Hugo theme" | |
| 97 | - | |
| 98 | - | |
| 99 | -# CLI entry point (used by cli.py) | |
| 100 | -def hugoify(path: str) -> str: | |
| 101 | - """ | |
| 102 | - Entry point for the CLI 'hugoify' command. | |
| 103 | - If path is a Hugo theme dir: validate it. | |
| 104 | - If path is an HTML file or raw HTML dir: convert it. | |
| 105 | - """ | |
| 106 | - from utils.theme_finder import find_hugo_theme, find_raw_html_files | |
| 107 | - | |
| 108 | - info = find_hugo_theme(path) | |
| 109 | - if info: | |
| 110 | - return hugoify_dir(info['theme_dir']) | |
| 111 | - | |
| 112 | - if os.path.isfile(path) and path.endswith('.html'): | |
| 113 | - layouts = hugoify_html(path) | |
| 114 | - return f"Converted to {len(layouts)} layout files: {list(layouts.keys())}" | |
| 115 | - | |
| 116 | - html_files = find_raw_html_files(path) | |
| 117 | - if html_files: | |
| 118 | - main = next( | |
| 119 | - (f for f in html_files if os.path.basename(f).lower() == 'index.html'), | |
| 120 | - html_files[0] | |
| 121 | - ) | |
| 122 | - layouts = hugoify_html(main) | |
| 123 | - return f"Converted to {len(layouts)} layout files" | |
| 124 | - | |
| 125 | - return f"Nothing to hugoify at {path}" | |
| 126 | - | |
| 127 | - | |
| 128 | -# --------------------------------------------------------------------------- | |
| 129 | -# Helpers | |
| 130 | -# --------------------------------------------------------------------------- | |
| 131 | - | |
| 132 | -def _parse_layout_json(response: str) -> dict: | |
| 133 | - """Extract JSON from AI response, even if surrounded by prose.""" | |
| 134 | - # Try to find JSON block | |
| 135 | - match = re.search(r'\{.*\}', response, re.DOTALL) | |
| 136 | - if match: | |
| 137 | - try: | |
| 138 | - return json.loads(match.group(0)) | |
| 139 | - except json.JSONDecodeError: | |
| 140 | - pass | |
| 141 | - | |
| 142 | - # Fallback: return a minimal layout | |
| 143 | - logging.warning("Could not parse AI response as JSON, using fallback layouts") | |
| 144 | - return { | |
| 145 | - "_default/baseof.html": _fallback_baseof(), | |
| 146 | - "partials/header.html": "<header><!-- header --></header>", | |
| 147 | - "partials/footer.html": "<footer>{{ .Site.Params.copyright }}</footer>", | |
| 148 | - "index.html": '{{ define "main" }}<main>{{ .Content }}</main>{{ end }}', | |
| 149 | - } | |
| 150 | - | |
| 151 | - | |
| 152 | -def _fallback_baseof() -> str: | |
| 153 | - return '''<!DOCTYPE html> | |
| 154 | -<html lang="{{ with .Site.LanguageCode }}{{ . }}{{ else }}en-US{{ end }}"> | |
| 155 | -<head> | |
| 156 | - <meta charset="UTF-8"> | |
| 157 | - <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 158 | - <title>{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} | {{ .Site.Title }}{{ end }}</title> | |
| 159 | -</head> | |
| 160 | -<body> | |
| 161 | - {{- partial "header.html" . -}} | |
| 162 | - {{- block "main" . }}{{- end }} | |
| 163 | - {{- partial "footer.html" . -}} | |
| 164 | -</body> | |
| 165 | -</html>''' |
| --- a/src/utils/hugoify.py | |
| +++ b/src/utils/hugoify.py | |
| @@ -1,165 +0,0 @@ | |
| 1 | """ |
| 2 | AI-powered HTML → Hugo template conversion. |
| 3 | |
| 4 | For already-Hugo themes, use hugoify_dir() to validate/augment. |
| 5 | For raw HTML, use hugoify_html() to produce Hugo layout files. |
| 6 | """ |
| 7 | |
| 8 | import logging |
| 9 | import os |
| 10 | import json |
| 11 | import re |
| 12 | |
| 13 | from config import call_ai |
| 14 | |
| 15 | SYSTEM = """You are an expert Hugo theme developer. Convert HTML templates to valid Hugo Go template files. |
| 16 | Output only valid Hugo template syntax — no explanations, no markdown fences.""" |
| 17 | |
| 18 | |
| 19 | def hugoify_html(html_path: str) -> dict: |
| 20 | """ |
| 21 | Convert a raw HTML file to a set of Hugo layout files. |
| 22 | |
| 23 | Returns dict mapping relative layout paths to their content, e.g.: |
| 24 | { |
| 25 | "_default/baseof.html": "<!DOCTYPE html>...", |
| 26 | "partials/header.html": "<header>...", |
| 27 | "partials/footer.html": "<footer>...", |
| 28 | "index.html": "{{ define \"main\" }}...", |
| 29 | } |
| 30 | """ |
| 31 | logging.info(f"Hugoifying {html_path} ...") |
| 32 | |
| 33 | with open(html_path, 'r', errors='replace') as f: |
| 34 | html = f.read() |
| 35 | |
| 36 | # Truncate very large files to avoid token limits |
| 37 | if len(html) > 30000: |
| 38 | logging.warning(f"HTML is large ({len(html)} chars), truncating to 30000 for AI analysis") |
| 39 | html = html[:30000] |
| 40 | |
| 41 | prompt = f"""Convert the following HTML file into Hugo layout files. |
| 42 | |
| 43 | Return a JSON object where keys are relative file paths under layouts/ and values are the Hugo template content. |
| 44 | |
| 45 | Required keys to produce: |
| 46 | - "_default/baseof.html" — base template with blocks for head, header, main, footer |
| 47 | - "partials/header.html" — site header/nav extracted as partial |
| 48 | - "partials/footer.html" — footer extracted as partial |
| 49 | - "index.html" — homepage using {{ define "main" }} ... {{ end }} |
| 50 | |
| 51 | Rules: |
| 52 | - Replace hardcoded page titles with {{ .Title }} |
| 53 | - Replace hardcoded site name with {{ .Site.Title }} |
| 54 | - Replace hardcoded URLs with {{ .Site.BaseURL }} or {{ .Permalink }} |
| 55 | - Replace nav links with {{ range .Site.Menus.main }}<a href="{{ .URL }}">{{ .Name }}</a>{{ end }} |
| 56 | - Replace blog post lists with {{ range .Pages }} ... {{ end }} |
| 57 | - Replace copyright year with {{ now.Year }} |
| 58 | - Keep all CSS classes and HTML structure intact |
| 59 | - Use {{ partial "header.html" . }} and {{ partial "footer.html" . }} in baseof.html |
| 60 | |
| 61 | HTML to convert: |
| 62 | {html} |
| 63 | |
| 64 | Return ONLY a valid JSON object, no explanation.""" |
| 65 | |
| 66 | response = call_ai(prompt, SYSTEM) |
| 67 | return _parse_layout_json(response) |
| 68 | |
| 69 | |
| 70 | def hugoify_dir(theme_dir: str) -> str: |
| 71 | """ |
| 72 | Validate and optionally augment an existing Hugo theme directory. |
| 73 | Returns a status message. |
| 74 | """ |
| 75 | logging.info(f"Validating Hugo theme at {theme_dir} ...") |
| 76 | |
| 77 | issues = [] |
| 78 | layouts_dir = os.path.join(theme_dir, 'layouts') |
| 79 | |
| 80 | if not os.path.isdir(layouts_dir): |
| 81 | issues.append("Missing layouts/ directory") |
| 82 | return f"Validation failed: {'; '.join(issues)}" |
| 83 | |
| 84 | required = [ |
| 85 | os.path.join(layouts_dir, '_default', 'baseof.html'), |
| 86 | ] |
| 87 | for f in required: |
| 88 | if not os.path.exists(f): |
| 89 | issues.append(f"Missing {os.path.relpath(f, theme_dir)}") |
| 90 | |
| 91 | if issues: |
| 92 | logging.warning(f"Issues found: {issues}") |
| 93 | return f"Issues: {'; '.join(issues)}" |
| 94 | |
| 95 | logging.info("Hugo theme validation passed.") |
| 96 | return "Valid Hugo theme" |
| 97 | |
| 98 | |
| 99 | # CLI entry point (used by cli.py) |
| 100 | def hugoify(path: str) -> str: |
| 101 | """ |
| 102 | Entry point for the CLI 'hugoify' command. |
| 103 | If path is a Hugo theme dir: validate it. |
| 104 | If path is an HTML file or raw HTML dir: convert it. |
| 105 | """ |
| 106 | from utils.theme_finder import find_hugo_theme, find_raw_html_files |
| 107 | |
| 108 | info = find_hugo_theme(path) |
| 109 | if info: |
| 110 | return hugoify_dir(info['theme_dir']) |
| 111 | |
| 112 | if os.path.isfile(path) and path.endswith('.html'): |
| 113 | layouts = hugoify_html(path) |
| 114 | return f"Converted to {len(layouts)} layout files: {list(layouts.keys())}" |
| 115 | |
| 116 | html_files = find_raw_html_files(path) |
| 117 | if html_files: |
| 118 | main = next( |
| 119 | (f for f in html_files if os.path.basename(f).lower() == 'index.html'), |
| 120 | html_files[0] |
| 121 | ) |
| 122 | layouts = hugoify_html(main) |
| 123 | return f"Converted to {len(layouts)} layout files" |
| 124 | |
| 125 | return f"Nothing to hugoify at {path}" |
| 126 | |
| 127 | |
| 128 | # --------------------------------------------------------------------------- |
| 129 | # Helpers |
| 130 | # --------------------------------------------------------------------------- |
| 131 | |
| 132 | def _parse_layout_json(response: str) -> dict: |
| 133 | """Extract JSON from AI response, even if surrounded by prose.""" |
| 134 | # Try to find JSON block |
| 135 | match = re.search(r'\{.*\}', response, re.DOTALL) |
| 136 | if match: |
| 137 | try: |
| 138 | return json.loads(match.group(0)) |
| 139 | except json.JSONDecodeError: |
| 140 | pass |
| 141 | |
| 142 | # Fallback: return a minimal layout |
| 143 | logging.warning("Could not parse AI response as JSON, using fallback layouts") |
| 144 | return { |
| 145 | "_default/baseof.html": _fallback_baseof(), |
| 146 | "partials/header.html": "<header><!-- header --></header>", |
| 147 | "partials/footer.html": "<footer>{{ .Site.Params.copyright }}</footer>", |
| 148 | "index.html": '{{ define "main" }}<main>{{ .Content }}</main>{{ end }}', |
| 149 | } |
| 150 | |
| 151 | |
| 152 | def _fallback_baseof() -> str: |
| 153 | return '''<!DOCTYPE html> |
| 154 | <html lang="{{ with .Site.LanguageCode }}{{ . }}{{ else }}en-US{{ end }}"> |
| 155 | <head> |
| 156 | <meta charset="UTF-8"> |
| 157 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 158 | <title>{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} | {{ .Site.Title }}{{ end }}</title> |
| 159 | </head> |
| 160 | <body> |
| 161 | {{- partial "header.html" . -}} |
| 162 | {{- block "main" . }}{{- end }} |
| 163 | {{- partial "footer.html" . -}} |
| 164 | </body> |
| 165 | </html>''' |
| --- a/src/utils/hugoify.py | |
| +++ b/src/utils/hugoify.py | |
| @@ -1,165 +0,0 @@ | |
D
src/utils/parser.py
-31
| --- a/src/utils/parser.py | ||
| +++ b/src/utils/parser.py | ||
| @@ -1,31 +0,0 @@ | ||
| 1 | -""" | |
| 2 | -This script performs parsing, linting, and validation of web content to ensure it adheres to best practices and standards. | |
| 3 | -It checks for syntax errors and validates the structure of the content. | |
| 4 | -""" | |
| 5 | - | |
| 6 | -import logging | |
| 7 | - | |
| 8 | -# Function to perform parsing, linting, and validation | |
| 9 | -def parse(path): | |
| 10 | - logging.info(f"Starting parsing and linting for {path}...") | |
| 11 | - try: | |
| 12 | - # Parse input | |
| 13 | - logging.info("Parsing input...") | |
| 14 | - # Example parsing logic | |
| 15 | - # parse_html_structure(path) | |
| 16 | - | |
| 17 | - # Lint code | |
| 18 | - logging.info("Linting code...") | |
| 19 | - # Example linting logic | |
| 20 | - # lint_html_css_js(path) | |
| 21 | - | |
| 22 | - # Validate structure | |
| 23 | - logging.info("Validating structure...") | |
| 24 | - # Example validation logic | |
| 25 | - # validate_html_structure(path) | |
| 26 | - | |
| 27 | - logging.info("Parsing complete.") | |
| 28 | - return "Parsing complete" | |
| 29 | - except Exception as e: | |
| 30 | - logging.error(f"Error during parsing: {e}") | |
| 31 | - return "Parsing failed" |
| --- a/src/utils/parser.py | |
| +++ b/src/utils/parser.py | |
| @@ -1,31 +0,0 @@ | |
| 1 | """ |
| 2 | This script performs parsing, linting, and validation of web content to ensure it adheres to best practices and standards. |
| 3 | It checks for syntax errors and validates the structure of the content. |
| 4 | """ |
| 5 | |
| 6 | import logging |
| 7 | |
| 8 | # Function to perform parsing, linting, and validation |
| 9 | def parse(path): |
| 10 | logging.info(f"Starting parsing and linting for {path}...") |
| 11 | try: |
| 12 | # Parse input |
| 13 | logging.info("Parsing input...") |
| 14 | # Example parsing logic |
| 15 | # parse_html_structure(path) |
| 16 | |
| 17 | # Lint code |
| 18 | logging.info("Linting code...") |
| 19 | # Example linting logic |
| 20 | # lint_html_css_js(path) |
| 21 | |
| 22 | # Validate structure |
| 23 | logging.info("Validating structure...") |
| 24 | # Example validation logic |
| 25 | # validate_html_structure(path) |
| 26 | |
| 27 | logging.info("Parsing complete.") |
| 28 | return "Parsing complete" |
| 29 | except Exception as e: |
| 30 | logging.error(f"Error during parsing: {e}") |
| 31 | return "Parsing failed" |
| --- a/src/utils/parser.py | |
| +++ b/src/utils/parser.py | |
| @@ -1,31 +0,0 @@ | |
D
src/utils/theme_finder.py
-74
| --- a/src/utils/theme_finder.py | ||
| +++ b/src/utils/theme_finder.py | ||
| @@ -1,74 +0,0 @@ | ||
| 1 | -""" | |
| 2 | -Locates the actual Hugo theme and exampleSite within the messy zip-extracted structure. | |
| 3 | -Themes in themes/ are structured as: {name}/{name}/themes/{theme-name}/ | |
| 4 | -""" | |
| 5 | - | |
| 6 | -import }/ | |
| 7 | -""" | |
| 8 | - | |
| 9 | -import json | |
| 10 | -import logging | |
| 11 | -import os | |
| 12 | - | |
| 13 | - | |
| 14 | -def find_hugo_theme(input_path): | |
| 15 | - """ | |
| 16 | - Given a path like themes/revolve-hugo or the inner extracted dir, | |
| 17 | - find the Hugo theme directory (has layouts/), the exampleSite, and the theme name. | |
| 18 | - | |
| 19 | - Returns dict with: | |
| 20 | - theme_dir: path to the Hugo theme (has layouts/, archetypes/, etc.) | |
| 21 | - example_site: path to exampleSite dir (may be None) | |
| 22 | - theme_name: name of the theme (used in hugo.toml) | |
| 23 | - is_hugo_theme: True if input is already a Hugo theme | |
| 24 | - """ | |
| 25 | - input_path = os.path.abspath(input_path) | |
| 26 | - | |
| 27 | - # Walk up to find the theme dir containing layouts/ | |
| 28 | - candidates = [] | |
| 29 | - for root, dirs, files in os.walk(input_path): | |
| 30 | - # Skip __MACOSX junk | |
| 31 | - if '__MACOSX' in root: | |
| 32 | - continue | |
| 33 | - if 'layouts' in dirs and '_default' in os.listdir(os.path.join(root, 'layouts')): | |
| 34 | - candidates.append(root) | |
| 35 | - | |
| 36 | - if not candidates: | |
| 37 | - return None | |
| 38 | - | |
| 39 | - # Pick the deepest match (most likely the actual theme dir) | |
| 40 | - theme_dir = max(candidates, key=lambda p: p.count(os.sep)) | |
| 41 | - if len(candidates) > 1: | |
| 42 | - logging.warning( | |
| 43 | - f"Multiple Hugo theme candidates found; using {theme_dir!r}. " | |
| 44 | - f"Others: {[c for c in candidates if c != theme_dir]}" | |
| 45 | - ) | |
| 46 | - | |
| 47 | - # Detect exampleSite | |
| 48 | - example_site = None | |
| 49 | - for candidate in [ | |
| 50 | - os.path.join(theme_dir, 'exampleSite'), | |
| 51 | - os.path.join(os.path.dirname(theme_dir), 'exampleSite'), | |
| 52 | - ]: | |
| 53 | - if os.path.isdir(candidate): | |
| 54 | - example_site = candidate | |
| 55 | - break | |
| 56 | - | |
| 57 | - theme_name = os.path.basename(theme_dir) | |
| 58 | - | |
| 59 | - return { | |
| 60 | - 'theme_dir': theme_dir, | |
| 61 | - 'example_site': example_site, | |
| 62 | - 'theme_name': theme_name, | |
| 63 | - 'is_hugo_them return None | |
| 64 | - | |
| 65 | - | |
| 66 | -def find_raw_html_files(input_path): | |
| 67 | - """Find HTML files in a raw HTML theme (not a Hugo theme).""" | |
| 68 | - html_files = [] | |
| 69 | - for root, dirs, files in os.walk(input_path): | |
| 70 | - if '__MACOSX' in root: | |
| 71 | - continue | |
| 72 | - for f in files: | |
| 73 | - if f.endswith('.html') and 'exampleSite' not in root: | |
| 74 | - html_files.append(os.path.join(root, |
| --- a/src/utils/theme_finder.py | |
| +++ b/src/utils/theme_finder.py | |
| @@ -1,74 +0,0 @@ | |
| 1 | """ |
| 2 | Locates the actual Hugo theme and exampleSite within the messy zip-extracted structure. |
| 3 | Themes in themes/ are structured as: {name}/{name}/themes/{theme-name}/ |
| 4 | """ |
| 5 | |
| 6 | import }/ |
| 7 | """ |
| 8 | |
| 9 | import json |
| 10 | import logging |
| 11 | import os |
| 12 | |
| 13 | |
| 14 | def find_hugo_theme(input_path): |
| 15 | """ |
| 16 | Given a path like themes/revolve-hugo or the inner extracted dir, |
| 17 | find the Hugo theme directory (has layouts/), the exampleSite, and the theme name. |
| 18 | |
| 19 | Returns dict with: |
| 20 | theme_dir: path to the Hugo theme (has layouts/, archetypes/, etc.) |
| 21 | example_site: path to exampleSite dir (may be None) |
| 22 | theme_name: name of the theme (used in hugo.toml) |
| 23 | is_hugo_theme: True if input is already a Hugo theme |
| 24 | """ |
| 25 | input_path = os.path.abspath(input_path) |
| 26 | |
| 27 | # Walk up to find the theme dir containing layouts/ |
| 28 | candidates = [] |
| 29 | for root, dirs, files in os.walk(input_path): |
| 30 | # Skip __MACOSX junk |
| 31 | if '__MACOSX' in root: |
| 32 | continue |
| 33 | if 'layouts' in dirs and '_default' in os.listdir(os.path.join(root, 'layouts')): |
| 34 | candidates.append(root) |
| 35 | |
| 36 | if not candidates: |
| 37 | return None |
| 38 | |
| 39 | # Pick the deepest match (most likely the actual theme dir) |
| 40 | theme_dir = max(candidates, key=lambda p: p.count(os.sep)) |
| 41 | if len(candidates) > 1: |
| 42 | logging.warning( |
| 43 | f"Multiple Hugo theme candidates found; using {theme_dir!r}. " |
| 44 | f"Others: {[c for c in candidates if c != theme_dir]}" |
| 45 | ) |
| 46 | |
| 47 | # Detect exampleSite |
| 48 | example_site = None |
| 49 | for candidate in [ |
| 50 | os.path.join(theme_dir, 'exampleSite'), |
| 51 | os.path.join(os.path.dirname(theme_dir), 'exampleSite'), |
| 52 | ]: |
| 53 | if os.path.isdir(candidate): |
| 54 | example_site = candidate |
| 55 | break |
| 56 | |
| 57 | theme_name = os.path.basename(theme_dir) |
| 58 | |
| 59 | return { |
| 60 | 'theme_dir': theme_dir, |
| 61 | 'example_site': example_site, |
| 62 | 'theme_name': theme_name, |
| 63 | 'is_hugo_them return None |
| 64 | |
| 65 | |
| 66 | def find_raw_html_files(input_path): |
| 67 | """Find HTML files in a raw HTML theme (not a Hugo theme).""" |
| 68 | html_files = [] |
| 69 | for root, dirs, files in os.walk(input_path): |
| 70 | if '__MACOSX' in root: |
| 71 | continue |
| 72 | for f in files: |
| 73 | if f.endswith('.html') and 'exampleSite' not in root: |
| 74 | html_files.append(os.path.join(root, |
| --- a/src/utils/theme_finder.py | |
| +++ b/src/utils/theme_finder.py | |
| @@ -1,74 +0,0 @@ | |
D
src/utils/theme_patcher.py
-72
| --- a/src/utils/theme_patcher.py | ||
| +++ b/src/utils/theme_patcher.py | ||
| @@ -1,72 +0,0 @@ | ||
| 1 | -""" | |
| 2 | -Patches common Hugo deprecations in theme layout files so they work with Hugo >= v0.128. | |
| 3 | - | |
| 4 | -Call patch_theme(theme_dir) after copying theme files to the output directory. | |
| 5 | -""" | |
| 6 | - | |
| 7 | -import logging | |
| 8 | -import os | |
| 9 | -import re | |
| 10 | - | |
| 11 | -# Map of (pattern, replacement) for deprecated Hugo template variables/functions | |
| 12 | -TEMPLATE_PATCHES = [ | |
| 13 | - # .Site.DisqusShortname → .Site.Config.Services.Disqus.Shortname | |
| 14 | - (r'\.Site\.DisqusShortname', '.Site.Config.Services.Disqus.Shortname'), | |
| 15 | - # .Site.GoogleAnalytics → .Site.Config.Services.GoogleAnalytics.ID | |
| 16 | - (r'\.Site\.GoogleAnalytics\b', '.Site.Config.Services.GoogleAnalytics.ID'), | |
| 17 | - # absLangURL → absLangURL still works but absURL is preferred for simple cases | |
| 18 | - # safeHTMLAttr is fine, no change needed | |
| 19 | -] | |
| 20 | - | |
| 21 | -# Config key patches: (old_pattern, replacement) | |
| 22 | -CONFIG_PATCHES = [ | |
| 23 | - # paginate → [pagination] pagerSize | |
| 24 | - (r'^paginate\s*=\s*(\d+)$', r'[pagination]\n pagerSize = \1'), | |
| 25 | - # googleAnalytics = "UA-xxx" → [services.googleAnalytics] id = "UA-xxx" | |
| 26 | - (r'^googleAnalytics\s*=\s*"([^"]+)"', r'[services.googleAnalytics]\n id = "\1"'), | |
| 27 | - # disqusShortname = "xxx" → [services.disqus] shortname = "xxx" | |
| 28 | - (r'^disqusShortname\s*=\s*"([^"]+)"', r'[services.disqus]\n shortname = "\1"'), | |
| 29 | -] | |
| 30 | - | |
| 31 | - | |
| 32 | -def patch_theme(theme_dir: str): | |
| 33 | - """Patch deprecated Hugo APIs in all layout files under theme_dir/layouts/.""" | |
| 34 | - layouts_dir = os.path.join(theme_dir, 'layouts') | |
| 35 | - if not os.path.isdir(layouts_dir): | |
| 36 | - return | |
| 37 | - | |
| 38 | - patched = 0 | |
| 39 | - for root, dirs, files in os.walk(layouts_dir): | |
| 40 | - for fname in files: | |
| 41 | - if not fname.endswith('.html'): | |
| 42 | - continue | |
| 43 | - path = os.path.join(root, fname) | |
| 44 | - with open(path, 'r', errors='replace') as f: | |
| 45 | - content = f.read() | |
| 46 | - | |
| 47 | - new_content = content | |
| 48 | - for pattern, replacement in TEMPLATE_PATCHES: | |
| 49 | - new_content = re.sub(pattern, replacement, new_content) | |
| 50 | - | |
| 51 | - if new_content != content: | |
| 52 | - with open(path, 'w') as f: | |
| 53 | - f.write(new_content) | |
| 54 | - patched += 1 | |
| 55 | - | |
| 56 | - if patched: | |
| 57 | - logging.info(f"Patched {patched} template file(s) in {theme_dir}") | |
| 58 | - | |
| 59 | - | |
| 60 | -def patch_config(config_path: str): | |
| 61 | - """Patch deprecated keys in a hugo.toml / config.toml file.""" | |
| 62 | - with open(config_path, 'r') as f: | |
| 63 | - content = f.read() | |
| 64 | - | |
| 65 | - new_content = content | |
| 66 | - for pattern, replacement in CONFIG_PATCHES: | |
| 67 | - new_content = re.sub(pattern, replacement, new_content, flags=re.MULTILINE) | |
| 68 | - | |
| 69 | - if new_content != content: | |
| 70 | - with open(config_path, 'w') as f: | |
| 71 | - f.write(new_content) | |
| 72 | - logging.info(f"Patched deprecated config keys in {config_path}") |
| --- a/src/utils/theme_patcher.py | |
| +++ b/src/utils/theme_patcher.py | |
| @@ -1,72 +0,0 @@ | |
| 1 | """ |
| 2 | Patches common Hugo deprecations in theme layout files so they work with Hugo >= v0.128. |
| 3 | |
| 4 | Call patch_theme(theme_dir) after copying theme files to the output directory. |
| 5 | """ |
| 6 | |
| 7 | import logging |
| 8 | import os |
| 9 | import re |
| 10 | |
| 11 | # Map of (pattern, replacement) for deprecated Hugo template variables/functions |
| 12 | TEMPLATE_PATCHES = [ |
| 13 | # .Site.DisqusShortname → .Site.Config.Services.Disqus.Shortname |
| 14 | (r'\.Site\.DisqusShortname', '.Site.Config.Services.Disqus.Shortname'), |
| 15 | # .Site.GoogleAnalytics → .Site.Config.Services.GoogleAnalytics.ID |
| 16 | (r'\.Site\.GoogleAnalytics\b', '.Site.Config.Services.GoogleAnalytics.ID'), |
| 17 | # absLangURL → absLangURL still works but absURL is preferred for simple cases |
| 18 | # safeHTMLAttr is fine, no change needed |
| 19 | ] |
| 20 | |
| 21 | # Config key patches: (old_pattern, replacement) |
| 22 | CONFIG_PATCHES = [ |
| 23 | # paginate → [pagination] pagerSize |
| 24 | (r'^paginate\s*=\s*(\d+)$', r'[pagination]\n pagerSize = \1'), |
| 25 | # googleAnalytics = "UA-xxx" → [services.googleAnalytics] id = "UA-xxx" |
| 26 | (r'^googleAnalytics\s*=\s*"([^"]+)"', r'[services.googleAnalytics]\n id = "\1"'), |
| 27 | # disqusShortname = "xxx" → [services.disqus] shortname = "xxx" |
| 28 | (r'^disqusShortname\s*=\s*"([^"]+)"', r'[services.disqus]\n shortname = "\1"'), |
| 29 | ] |
| 30 | |
| 31 | |
| 32 | def patch_theme(theme_dir: str): |
| 33 | """Patch deprecated Hugo APIs in all layout files under theme_dir/layouts/.""" |
| 34 | layouts_dir = os.path.join(theme_dir, 'layouts') |
| 35 | if not os.path.isdir(layouts_dir): |
| 36 | return |
| 37 | |
| 38 | patched = 0 |
| 39 | for root, dirs, files in os.walk(layouts_dir): |
| 40 | for fname in files: |
| 41 | if not fname.endswith('.html'): |
| 42 | continue |
| 43 | path = os.path.join(root, fname) |
| 44 | with open(path, 'r', errors='replace') as f: |
| 45 | content = f.read() |
| 46 | |
| 47 | new_content = content |
| 48 | for pattern, replacement in TEMPLATE_PATCHES: |
| 49 | new_content = re.sub(pattern, replacement, new_content) |
| 50 | |
| 51 | if new_content != content: |
| 52 | with open(path, 'w') as f: |
| 53 | f.write(new_content) |
| 54 | patched += 1 |
| 55 | |
| 56 | if patched: |
| 57 | logging.info(f"Patched {patched} template file(s) in {theme_dir}") |
| 58 | |
| 59 | |
| 60 | def patch_config(config_path: str): |
| 61 | """Patch deprecated keys in a hugo.toml / config.toml file.""" |
| 62 | with open(config_path, 'r') as f: |
| 63 | content = f.read() |
| 64 | |
| 65 | new_content = content |
| 66 | for pattern, replacement in CONFIG_PATCHES: |
| 67 | new_content = re.sub(pattern, replacement, new_content, flags=re.MULTILINE) |
| 68 | |
| 69 | if new_content != content: |
| 70 | with open(config_path, 'w') as f: |
| 71 | f.write(new_content) |
| 72 | logging.info(f"Patched deprecated config keys in {config_path}") |
| --- a/src/utils/theme_patcher.py | |
| +++ b/src/utils/theme_patcher.py | |
| @@ -1,72 +0,0 @@ | |
D
src/utils/translate.py
-24
| --- a/src/utils/translate.py | ||
| +++ b/src/utils/translate.py | ||
| @@ -1,24 +0,0 @@ | ||
| 1 | -""" | |
| 2 | -Translates web content using the configured AI backend. | |
| 3 | -""" | |
| 4 | - | |
| 5 | -import logging | |
| 6 | -from config import call_ai | |
| 7 | - | |
| 8 | - | |
| 9 | -def translate(path: str, target_language: str = "Spanish") -> str: | |
| 10 | - logging.info(f"Translating content in {path} ...") | |
| 11 | - try: | |
| 12 | - with open(path, 'r', errors='replace') as f: | |
| 13 | - content = f.read() | |
| 14 | - | |
| 15 | - prompt = f"""Translate the following web content to {target_language}. | |
| 16 | -Preserve all HTML tags and formatting. Only translate visible text. | |
| 17 | - | |
| 18 | -Content: | |
| 19 | -{content[:20000]}""" | |
| 20 | - | |
| 21 | - return call_ai(prompt, "You are a professional translator.") | |
| 22 | - except Exception as e: | |
| 23 | - logging.error(f"Translation failed: {e}") | |
| 24 | - return f"Translation failed: {e}" |
| --- a/src/utils/translate.py | |
| +++ b/src/utils/translate.py | |
| @@ -1,24 +0,0 @@ | |
| 1 | """ |
| 2 | Translates web content using the configured AI backend. |
| 3 | """ |
| 4 | |
| 5 | import logging |
| 6 | from config import call_ai |
| 7 | |
| 8 | |
| 9 | def translate(path: str, target_language: str = "Spanish") -> str: |
| 10 | logging.info(f"Translating content in {path} ...") |
| 11 | try: |
| 12 | with open(path, 'r', errors='replace') as f: |
| 13 | content = f.read() |
| 14 | |
| 15 | prompt = f"""Translate the following web content to {target_language}. |
| 16 | Preserve all HTML tags and formatting. Only translate visible text. |
| 17 | |
| 18 | Content: |
| 19 | {content[:20000]}""" |
| 20 | |
| 21 | return call_ai(prompt, "You are a professional translator.") |
| 22 | except Exception as e: |
| 23 | logging.error(f"Translation failed: {e}") |
| 24 | return f"Translation failed: {e}" |
| --- a/src/utils/translate.py | |
| +++ b/src/utils/translate.py | |
| @@ -1,24 +0,0 @@ | |
+3
-3
| --- tests/conftest.py | ||
| +++ tests/conftest.py | ||
| @@ -1,5 +1,5 @@ | ||
| 1 | -"""Add src/ to sys.path so tests can import from utils.* directly.""" | |
| 2 | -import sys | |
| 1 | +"""Add repo root to sys.path so tests can import hugoifier.*.""" | |
| 3 | 2 | import os |
| 3 | +import sys | |
| 4 | 4 | |
| 5 | -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) | |
| 5 | +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) | |
| 6 | 6 |
| --- tests/conftest.py | |
| +++ tests/conftest.py | |
| @@ -1,5 +1,5 @@ | |
| 1 | """Add src/ to sys.path so tests can import from utils.* directly.""" |
| 2 | import sys |
| 3 | import os |
| 4 | |
| 5 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) |
| 6 |
| --- tests/conftest.py | |
| +++ tests/conftest.py | |
| @@ -1,5 +1,5 @@ | |
| 1 | """Add repo root to sys.path so tests can import hugoifier.*.""" |
| 2 | import os |
| 3 | import sys |
| 4 | |
| 5 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) |
| 6 |
+1
-1
| --- tests/test_analyze.py | ||
| +++ tests/test_analyze.py | ||
| @@ -1,11 +1,11 @@ | ||
| 1 | 1 | """Tests for utils.analyze.""" |
| 2 | 2 | import os |
| 3 | 3 | import tempfile |
| 4 | 4 | import unittest |
| 5 | 5 | |
| 6 | -from utils.analyze import _analyze_hugo_theme | |
| 6 | +from hugoifier.utils.analyze import _analyze_hugo_theme | |
| 7 | 7 | |
| 8 | 8 | |
| 9 | 9 | class TestAnalyzeHugoTheme(unittest.TestCase): |
| 10 | 10 | def _make_theme_info(self, tmp, layouts=None, example_site=None): |
| 11 | 11 | theme_dir = os.path.join(tmp, "test-theme") |
| 12 | 12 |
| --- tests/test_analyze.py | |
| +++ tests/test_analyze.py | |
| @@ -1,11 +1,11 @@ | |
| 1 | """Tests for utils.analyze.""" |
| 2 | import os |
| 3 | import tempfile |
| 4 | import unittest |
| 5 | |
| 6 | from utils.analyze import _analyze_hugo_theme |
| 7 | |
| 8 | |
| 9 | class TestAnalyzeHugoTheme(unittest.TestCase): |
| 10 | def _make_theme_info(self, tmp, layouts=None, example_site=None): |
| 11 | theme_dir = os.path.join(tmp, "test-theme") |
| 12 |
| --- tests/test_analyze.py | |
| +++ tests/test_analyze.py | |
| @@ -1,11 +1,11 @@ | |
| 1 | """Tests for utils.analyze.""" |
| 2 | import os |
| 3 | import tempfile |
| 4 | import unittest |
| 5 | |
| 6 | from hugoifier.utils.analyze import _analyze_hugo_theme |
| 7 | |
| 8 | |
| 9 | class TestAnalyzeHugoTheme(unittest.TestCase): |
| 10 | def _make_theme_info(self, tmp, layouts=None, example_site=None): |
| 11 | theme_dir = os.path.join(tmp, "test-theme") |
| 12 |
+1
-1
| --- tests/test_cloudflare.py | ||
| +++ tests/test_cloudflare.py | ||
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | """Tests for utils.cloudflare.""" |
| 2 | 2 | import unittest |
| 3 | 3 | |
| 4 | -from utils.cloudflare import configure_cloudflare | |
| 4 | +from hugoifier.utils.cloudflare import configure_cloudflare | |
| 5 | 5 | |
| 6 | 6 | |
| 7 | 7 | class TestConfigureCloudflare(unittest.TestCase): |
| 8 | 8 | def test_returns_complete_message(self): |
| 9 | 9 | result = configure_cloudflare("/some/path", "example.com") |
| 10 | 10 |
| --- tests/test_cloudflare.py | |
| +++ tests/test_cloudflare.py | |
| @@ -1,9 +1,9 @@ | |
| 1 | """Tests for utils.cloudflare.""" |
| 2 | import unittest |
| 3 | |
| 4 | from utils.cloudflare import configure_cloudflare |
| 5 | |
| 6 | |
| 7 | class TestConfigureCloudflare(unittest.TestCase): |
| 8 | def test_returns_complete_message(self): |
| 9 | result = configure_cloudflare("/some/path", "example.com") |
| 10 |
| --- tests/test_cloudflare.py | |
| +++ tests/test_cloudflare.py | |
| @@ -1,9 +1,9 @@ | |
| 1 | """Tests for utils.cloudflare.""" |
| 2 | import unittest |
| 3 | |
| 4 | from hugoifier.utils.cloudflare import configure_cloudflare |
| 5 | |
| 6 | |
| 7 | class TestConfigureCloudflare(unittest.TestCase): |
| 8 | def test_returns_complete_message(self): |
| 9 | result = configure_cloudflare("/some/path", "example.com") |
| 10 |
+6
-1
| --- tests/test_complete.py | ||
| +++ tests/test_complete.py | ||
| @@ -1,11 +1,16 @@ | ||
| 1 | 1 | """Tests for utils.complete helpers.""" |
| 2 | 2 | import os |
| 3 | 3 | import tempfile |
| 4 | 4 | import unittest |
| 5 | 5 | |
| 6 | -from utils.complete import _pick_main_html, _copy_dir, _find_config, _write_minimal_hugo_toml | |
| 6 | +from hugoifier.utils.complete import ( | |
| 7 | + _copy_dir, | |
| 8 | + _find_config, | |
| 9 | + _pick_main_html, | |
| 10 | + _write_minimal_hugo_toml, | |
| 11 | +) | |
| 7 | 12 | |
| 8 | 13 | |
| 9 | 14 | class TestPickMainHtml(unittest.TestCase): |
| 10 | 15 | def test_prefers_index_html(self): |
| 11 | 16 | files = ["/path/about.html", "/path/index.html", "/path/contact.html"] |
| 12 | 17 |
| --- tests/test_complete.py | |
| +++ tests/test_complete.py | |
| @@ -1,11 +1,16 @@ | |
| 1 | """Tests for utils.complete helpers.""" |
| 2 | import os |
| 3 | import tempfile |
| 4 | import unittest |
| 5 | |
| 6 | from utils.complete import _pick_main_html, _copy_dir, _find_config, _write_minimal_hugo_toml |
| 7 | |
| 8 | |
| 9 | class TestPickMainHtml(unittest.TestCase): |
| 10 | def test_prefers_index_html(self): |
| 11 | files = ["/path/about.html", "/path/index.html", "/path/contact.html"] |
| 12 |
| --- tests/test_complete.py | |
| +++ tests/test_complete.py | |
| @@ -1,11 +1,16 @@ | |
| 1 | """Tests for utils.complete helpers.""" |
| 2 | import os |
| 3 | import tempfile |
| 4 | import unittest |
| 5 | |
| 6 | from hugoifier.utils.complete import ( |
| 7 | _copy_dir, |
| 8 | _find_config, |
| 9 | _pick_main_html, |
| 10 | _write_minimal_hugo_toml, |
| 11 | ) |
| 12 | |
| 13 | |
| 14 | class TestPickMainHtml(unittest.TestCase): |
| 15 | def test_prefers_index_html(self): |
| 16 | files = ["/path/about.html", "/path/index.html", "/path/contact.html"] |
| 17 |
+5
-4
| --- tests/test_config.py | ||
| +++ tests/test_config.py | ||
| @@ -6,11 +6,12 @@ | ||
| 6 | 6 | |
| 7 | 7 | class TestCallAiRouting(unittest.TestCase): |
| 8 | 8 | def _get_config(self): |
| 9 | 9 | # Reload config to pick up env var changes |
| 10 | 10 | import importlib |
| 11 | - import config | |
| 11 | + | |
| 12 | + import hugoifier.config as config | |
| 12 | 13 | importlib.reload(config) |
| 13 | 14 | return config |
| 14 | 15 | |
| 15 | 16 | def test_raises_on_unknown_backend(self): |
| 16 | 17 | with patch.dict(os.environ, {"HUGOIFIER_BACKEND": "unknown"}): |
| @@ -41,31 +42,31 @@ | ||
| 41 | 42 | cfg.GOOGLE_API_KEY = "" |
| 42 | 43 | with self.assertRaises(EnvironmentError): |
| 43 | 44 | cfg._call_google("prompt", "system") |
| 44 | 45 | |
| 45 | 46 | def test_anthropic_backend_calls_anthropic(self): |
| 46 | - import config | |
| 47 | + import hugoifier.config as config | |
| 47 | 48 | with patch.object(config, '_call_anthropic', return_value="response") as mock_fn: |
| 48 | 49 | config.BACKEND = 'anthropic' |
| 49 | 50 | result = config.call_ai("hello") |
| 50 | 51 | mock_fn.assert_called_once() |
| 51 | 52 | self.assertEqual(result, "response") |
| 52 | 53 | |
| 53 | 54 | def test_openai_backend_calls_openai(self): |
| 54 | - import config | |
| 55 | + import hugoifier.config as config | |
| 55 | 56 | with patch.object(config, '_call_openai', return_value="response") as mock_fn: |
| 56 | 57 | config.BACKEND = 'openai' |
| 57 | 58 | result = config.call_ai("hello") |
| 58 | 59 | mock_fn.assert_called_once() |
| 59 | 60 | self.assertEqual(result, "response") |
| 60 | 61 | |
| 61 | 62 | def test_google_backend_calls_google(self): |
| 62 | - import config | |
| 63 | + import hugoifier.config as config | |
| 63 | 64 | with patch.object(config, '_call_google', return_value="response") as mock_fn: |
| 64 | 65 | config.BACKEND = 'google' |
| 65 | 66 | result = config.call_ai("hello") |
| 66 | 67 | mock_fn.assert_called_once() |
| 67 | 68 | self.assertEqual(result, "response") |
| 68 | 69 | |
| 69 | 70 | |
| 70 | 71 | if __name__ == "__main__": |
| 71 | 72 | unittest.main() |
| 72 | 73 |
| --- tests/test_config.py | |
| +++ tests/test_config.py | |
| @@ -6,11 +6,12 @@ | |
| 6 | |
| 7 | class TestCallAiRouting(unittest.TestCase): |
| 8 | def _get_config(self): |
| 9 | # Reload config to pick up env var changes |
| 10 | import importlib |
| 11 | import config |
| 12 | importlib.reload(config) |
| 13 | return config |
| 14 | |
| 15 | def test_raises_on_unknown_backend(self): |
| 16 | with patch.dict(os.environ, {"HUGOIFIER_BACKEND": "unknown"}): |
| @@ -41,31 +42,31 @@ | |
| 41 | cfg.GOOGLE_API_KEY = "" |
| 42 | with self.assertRaises(EnvironmentError): |
| 43 | cfg._call_google("prompt", "system") |
| 44 | |
| 45 | def test_anthropic_backend_calls_anthropic(self): |
| 46 | import config |
| 47 | with patch.object(config, '_call_anthropic', return_value="response") as mock_fn: |
| 48 | config.BACKEND = 'anthropic' |
| 49 | result = config.call_ai("hello") |
| 50 | mock_fn.assert_called_once() |
| 51 | self.assertEqual(result, "response") |
| 52 | |
| 53 | def test_openai_backend_calls_openai(self): |
| 54 | import config |
| 55 | with patch.object(config, '_call_openai', return_value="response") as mock_fn: |
| 56 | config.BACKEND = 'openai' |
| 57 | result = config.call_ai("hello") |
| 58 | mock_fn.assert_called_once() |
| 59 | self.assertEqual(result, "response") |
| 60 | |
| 61 | def test_google_backend_calls_google(self): |
| 62 | import config |
| 63 | with patch.object(config, '_call_google', return_value="response") as mock_fn: |
| 64 | config.BACKEND = 'google' |
| 65 | result = config.call_ai("hello") |
| 66 | mock_fn.assert_called_once() |
| 67 | self.assertEqual(result, "response") |
| 68 | |
| 69 | |
| 70 | if __name__ == "__main__": |
| 71 | unittest.main() |
| 72 |
| --- tests/test_config.py | |
| +++ tests/test_config.py | |
| @@ -6,11 +6,12 @@ | |
| 6 | |
| 7 | class TestCallAiRouting(unittest.TestCase): |
| 8 | def _get_config(self): |
| 9 | # Reload config to pick up env var changes |
| 10 | import importlib |
| 11 | |
| 12 | import hugoifier.config as config |
| 13 | importlib.reload(config) |
| 14 | return config |
| 15 | |
| 16 | def test_raises_on_unknown_backend(self): |
| 17 | with patch.dict(os.environ, {"HUGOIFIER_BACKEND": "unknown"}): |
| @@ -41,31 +42,31 @@ | |
| 42 | cfg.GOOGLE_API_KEY = "" |
| 43 | with self.assertRaises(EnvironmentError): |
| 44 | cfg._call_google("prompt", "system") |
| 45 | |
| 46 | def test_anthropic_backend_calls_anthropic(self): |
| 47 | import hugoifier.config as config |
| 48 | with patch.object(config, '_call_anthropic', return_value="response") as mock_fn: |
| 49 | config.BACKEND = 'anthropic' |
| 50 | result = config.call_ai("hello") |
| 51 | mock_fn.assert_called_once() |
| 52 | self.assertEqual(result, "response") |
| 53 | |
| 54 | def test_openai_backend_calls_openai(self): |
| 55 | import hugoifier.config as config |
| 56 | with patch.object(config, '_call_openai', return_value="response") as mock_fn: |
| 57 | config.BACKEND = 'openai' |
| 58 | result = config.call_ai("hello") |
| 59 | mock_fn.assert_called_once() |
| 60 | self.assertEqual(result, "response") |
| 61 | |
| 62 | def test_google_backend_calls_google(self): |
| 63 | import hugoifier.config as config |
| 64 | with patch.object(config, '_call_google', return_value="response") as mock_fn: |
| 65 | config.BACKEND = 'google' |
| 66 | result = config.call_ai("hello") |
| 67 | mock_fn.assert_called_once() |
| 68 | self.assertEqual(result, "response") |
| 69 | |
| 70 | |
| 71 | if __name__ == "__main__": |
| 72 | unittest.main() |
| 73 |
+5
-5
| --- tests/test_decapify.py | ||
| +++ tests/test_decapify.py | ||
| @@ -1,17 +1,17 @@ | ||
| 1 | 1 | """Tests for utils.decapify.""" |
| 2 | 2 | import os |
| 3 | 3 | import tempfile |
| 4 | 4 | import unittest |
| 5 | 5 | |
| 6 | -from utils.decapify import ( | |
| 6 | +from hugoifier.utils.decapify import ( | |
| 7 | + _build_collections, | |
| 8 | + _infer_fields_for_file, | |
| 9 | + _infer_fields_for_folder, | |
| 10 | + _parse_frontmatter, | |
| 7 | 11 | _sanitize_color, |
| 8 | 12 | _widget_for_value, |
| 9 | - _parse_frontmatter, | |
| 10 | - _infer_fields_for_folder, | |
| 11 | - _infer_fields_for_file, | |
| 12 | - _build_collections, | |
| 13 | 13 | decapify, |
| 14 | 14 | ) |
| 15 | 15 | |
| 16 | 16 | |
| 17 | 17 | class TestSanitizeColor(unittest.TestCase): |
| 18 | 18 |
| --- tests/test_decapify.py | |
| +++ tests/test_decapify.py | |
| @@ -1,17 +1,17 @@ | |
| 1 | """Tests for utils.decapify.""" |
| 2 | import os |
| 3 | import tempfile |
| 4 | import unittest |
| 5 | |
| 6 | from utils.decapify import ( |
| 7 | _sanitize_color, |
| 8 | _widget_for_value, |
| 9 | _parse_frontmatter, |
| 10 | _infer_fields_for_folder, |
| 11 | _infer_fields_for_file, |
| 12 | _build_collections, |
| 13 | decapify, |
| 14 | ) |
| 15 | |
| 16 | |
| 17 | class TestSanitizeColor(unittest.TestCase): |
| 18 |
| --- tests/test_decapify.py | |
| +++ tests/test_decapify.py | |
| @@ -1,17 +1,17 @@ | |
| 1 | """Tests for utils.decapify.""" |
| 2 | import os |
| 3 | import tempfile |
| 4 | import unittest |
| 5 | |
| 6 | from hugoifier.utils.decapify import ( |
| 7 | _build_collections, |
| 8 | _infer_fields_for_file, |
| 9 | _infer_fields_for_folder, |
| 10 | _parse_frontmatter, |
| 11 | _sanitize_color, |
| 12 | _widget_for_value, |
| 13 | decapify, |
| 14 | ) |
| 15 | |
| 16 | |
| 17 | class TestSanitizeColor(unittest.TestCase): |
| 18 |
+1
-1
| --- tests/test_deploy.py | ||
| +++ tests/test_deploy.py | ||
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | """Tests for utils.deploy.""" |
| 2 | 2 | import unittest |
| 3 | 3 | |
| 4 | -from utils.deploy import deploy | |
| 4 | +from hugoifier.utils.deploy import deploy | |
| 5 | 5 | |
| 6 | 6 | |
| 7 | 7 | class TestDeploy(unittest.TestCase): |
| 8 | 8 | def test_returns_complete_message(self): |
| 9 | 9 | result = deploy("/some/path", "example.com") |
| 10 | 10 |
| --- tests/test_deploy.py | |
| +++ tests/test_deploy.py | |
| @@ -1,9 +1,9 @@ | |
| 1 | """Tests for utils.deploy.""" |
| 2 | import unittest |
| 3 | |
| 4 | from utils.deploy import deploy |
| 5 | |
| 6 | |
| 7 | class TestDeploy(unittest.TestCase): |
| 8 | def test_returns_complete_message(self): |
| 9 | result = deploy("/some/path", "example.com") |
| 10 |
| --- tests/test_deploy.py | |
| +++ tests/test_deploy.py | |
| @@ -1,9 +1,9 @@ | |
| 1 | """Tests for utils.deploy.""" |
| 2 | import unittest |
| 3 | |
| 4 | from hugoifier.utils.deploy import deploy |
| 5 | |
| 6 | |
| 7 | class TestDeploy(unittest.TestCase): |
| 8 | def test_returns_complete_message(self): |
| 9 | result = deploy("/some/path", "example.com") |
| 10 |
| --- tests/test_generate_decap_config.py | ||
| +++ tests/test_generate_decap_config.py | ||
| @@ -1,11 +1,11 @@ | ||
| 1 | 1 | """Tests for utils.generate_decap_config (thin wrapper around decapify).""" |
| 2 | 2 | import os |
| 3 | 3 | import tempfile |
| 4 | 4 | import unittest |
| 5 | 5 | |
| 6 | -from utils.generate_decap_config import generate_decap_config | |
| 6 | +from hugoifier.utils.generate_decap_config import generate_decap_config | |
| 7 | 7 | |
| 8 | 8 | |
| 9 | 9 | class TestGenerateDecapConfig(unittest.TestCase): |
| 10 | 10 | def test_creates_admin_directory(self): |
| 11 | 11 | with tempfile.TemporaryDirectory() as tmp: |
| 12 | 12 |
| --- tests/test_generate_decap_config.py | |
| +++ tests/test_generate_decap_config.py | |
| @@ -1,11 +1,11 @@ | |
| 1 | """Tests for utils.generate_decap_config (thin wrapper around decapify).""" |
| 2 | import os |
| 3 | import tempfile |
| 4 | import unittest |
| 5 | |
| 6 | from utils.generate_decap_config import generate_decap_config |
| 7 | |
| 8 | |
| 9 | class TestGenerateDecapConfig(unittest.TestCase): |
| 10 | def test_creates_admin_directory(self): |
| 11 | with tempfile.TemporaryDirectory() as tmp: |
| 12 |
| --- tests/test_generate_decap_config.py | |
| +++ tests/test_generate_decap_config.py | |
| @@ -1,11 +1,11 @@ | |
| 1 | """Tests for utils.generate_decap_config (thin wrapper around decapify).""" |
| 2 | import os |
| 3 | import tempfile |
| 4 | import unittest |
| 5 | |
| 6 | from hugoifier.utils.generate_decap_config import generate_decap_config |
| 7 | |
| 8 | |
| 9 | class TestGenerateDecapConfig(unittest.TestCase): |
| 10 | def test_creates_admin_directory(self): |
| 11 | with tempfile.TemporaryDirectory() as tmp: |
| 12 |
+1
-1
| --- tests/test_hugoify.py | ||
| +++ tests/test_hugoify.py | ||
| @@ -1,11 +1,11 @@ | ||
| 1 | 1 | """Tests for utils.hugoify.""" |
| 2 | 2 | import os |
| 3 | 3 | import tempfile |
| 4 | 4 | import unittest |
| 5 | 5 | |
| 6 | -from utils.hugoify import _parse_layout_json, _fallback_baseof, hugoify_dir | |
| 6 | +from hugoifier.utils.hugoify import _fallback_baseof, _parse_layout_json, hugoify_dir | |
| 7 | 7 | |
| 8 | 8 | |
| 9 | 9 | class TestParseLayoutJson(unittest.TestCase): |
| 10 | 10 | def test_parses_valid_json(self): |
| 11 | 11 | response = '{"_default/baseof.html": "<!doctype html>", "index.html": "{{ define \\"main\\" }}{{ end }}"}' |
| 12 | 12 |
| --- tests/test_hugoify.py | |
| +++ tests/test_hugoify.py | |
| @@ -1,11 +1,11 @@ | |
| 1 | """Tests for utils.hugoify.""" |
| 2 | import os |
| 3 | import tempfile |
| 4 | import unittest |
| 5 | |
| 6 | from utils.hugoify import _parse_layout_json, _fallback_baseof, hugoify_dir |
| 7 | |
| 8 | |
| 9 | class TestParseLayoutJson(unittest.TestCase): |
| 10 | def test_parses_valid_json(self): |
| 11 | response = '{"_default/baseof.html": "<!doctype html>", "index.html": "{{ define \\"main\\" }}{{ end }}"}' |
| 12 |
| --- tests/test_hugoify.py | |
| +++ tests/test_hugoify.py | |
| @@ -1,11 +1,11 @@ | |
| 1 | """Tests for utils.hugoify.""" |
| 2 | import os |
| 3 | import tempfile |
| 4 | import unittest |
| 5 | |
| 6 | from hugoifier.utils.hugoify import _fallback_baseof, _parse_layout_json, hugoify_dir |
| 7 | |
| 8 | |
| 9 | class TestParseLayoutJson(unittest.TestCase): |
| 10 | def test_parses_valid_json(self): |
| 11 | response = '{"_default/baseof.html": "<!doctype html>", "index.html": "{{ define \\"main\\" }}{{ end }}"}' |
| 12 |
+1
-1
| --- tests/test_parser.py | ||
| +++ tests/test_parser.py | ||
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | """Tests for utils.parser.""" |
| 2 | 2 | import unittest |
| 3 | 3 | |
| 4 | -from utils.parser import parse | |
| 4 | +from hugoifier.utils.parser import parse | |
| 5 | 5 | |
| 6 | 6 | |
| 7 | 7 | class TestParse(unittest.TestCase): |
| 8 | 8 | def test_returns_complete_message(self): |
| 9 | 9 | result = parse("/some/path") |
| 10 | 10 |
| --- tests/test_parser.py | |
| +++ tests/test_parser.py | |
| @@ -1,9 +1,9 @@ | |
| 1 | """Tests for utils.parser.""" |
| 2 | import unittest |
| 3 | |
| 4 | from utils.parser import parse |
| 5 | |
| 6 | |
| 7 | class TestParse(unittest.TestCase): |
| 8 | def test_returns_complete_message(self): |
| 9 | result = parse("/some/path") |
| 10 |
| --- tests/test_parser.py | |
| +++ tests/test_parser.py | |
| @@ -1,9 +1,9 @@ | |
| 1 | """Tests for utils.parser.""" |
| 2 | import unittest |
| 3 | |
| 4 | from hugoifier.utils.parser import parse |
| 5 | |
| 6 | |
| 7 | class TestParse(unittest.TestCase): |
| 8 | def test_returns_complete_message(self): |
| 9 | result = parse("/some/path") |
| 10 |
+1
-1
| --- tests/test_theme_finder.py | ||
| +++ tests/test_theme_finder.py | ||
| @@ -1,11 +1,11 @@ | ||
| 1 | 1 | """Tests for utils.theme_finder.""" |
| 2 | 2 | import os |
| 3 | 3 | import tempfile |
| 4 | 4 | import unittest |
| 5 | 5 | |
| 6 | -from utils.theme_finder import find_hugo_theme, find_raw_html_files | |
| 6 | +from hugoifier.utils.theme_finder import find_hugo_theme, find_raw_html_files | |
| 7 | 7 | |
| 8 | 8 | |
| 9 | 9 | def _make_hugo_theme(base_dir, theme_name="test-theme"): |
| 10 | 10 | """Create a minimal Hugo theme structure.""" |
| 11 | 11 | layouts = os.path.join(base_dir, theme_name, "layouts", "_default") |
| 12 | 12 |
| --- tests/test_theme_finder.py | |
| +++ tests/test_theme_finder.py | |
| @@ -1,11 +1,11 @@ | |
| 1 | """Tests for utils.theme_finder.""" |
| 2 | import os |
| 3 | import tempfile |
| 4 | import unittest |
| 5 | |
| 6 | from utils.theme_finder import find_hugo_theme, find_raw_html_files |
| 7 | |
| 8 | |
| 9 | def _make_hugo_theme(base_dir, theme_name="test-theme"): |
| 10 | """Create a minimal Hugo theme structure.""" |
| 11 | layouts = os.path.join(base_dir, theme_name, "layouts", "_default") |
| 12 |
| --- tests/test_theme_finder.py | |
| +++ tests/test_theme_finder.py | |
| @@ -1,11 +1,11 @@ | |
| 1 | """Tests for utils.theme_finder.""" |
| 2 | import os |
| 3 | import tempfile |
| 4 | import unittest |
| 5 | |
| 6 | from hugoifier.utils.theme_finder import find_hugo_theme, find_raw_html_files |
| 7 | |
| 8 | |
| 9 | def _make_hugo_theme(base_dir, theme_name="test-theme"): |
| 10 | """Create a minimal Hugo theme structure.""" |
| 11 | layouts = os.path.join(base_dir, theme_name, "layouts", "_default") |
| 12 |
+1
-1
| --- tests/test_theme_patcher.py | ||
| +++ tests/test_theme_patcher.py | ||
| @@ -1,11 +1,11 @@ | ||
| 1 | 1 | """Tests for utils.theme_patcher.""" |
| 2 | 2 | import os |
| 3 | 3 | import tempfile |
| 4 | 4 | import unittest |
| 5 | 5 | |
| 6 | -from utils.theme_patcher import patch_theme, patch_config | |
| 6 | +from hugoifier.utils.theme_patcher import patch_config, patch_theme | |
| 7 | 7 | |
| 8 | 8 | |
| 9 | 9 | class TestPatchTheme(unittest.TestCase): |
| 10 | 10 | def _write_layout(self, layouts_dir, name, content): |
| 11 | 11 | path = os.path.join(layouts_dir, name) |
| 12 | 12 |
| --- tests/test_theme_patcher.py | |
| +++ tests/test_theme_patcher.py | |
| @@ -1,11 +1,11 @@ | |
| 1 | """Tests for utils.theme_patcher.""" |
| 2 | import os |
| 3 | import tempfile |
| 4 | import unittest |
| 5 | |
| 6 | from utils.theme_patcher import patch_theme, patch_config |
| 7 | |
| 8 | |
| 9 | class TestPatchTheme(unittest.TestCase): |
| 10 | def _write_layout(self, layouts_dir, name, content): |
| 11 | path = os.path.join(layouts_dir, name) |
| 12 |
| --- tests/test_theme_patcher.py | |
| +++ tests/test_theme_patcher.py | |
| @@ -1,11 +1,11 @@ | |
| 1 | """Tests for utils.theme_patcher.""" |
| 2 | import os |
| 3 | import tempfile |
| 4 | import unittest |
| 5 | |
| 6 | from hugoifier.utils.theme_patcher import patch_config, patch_theme |
| 7 | |
| 8 | |
| 9 | class TestPatchTheme(unittest.TestCase): |
| 10 | def _write_layout(self, layouts_dir, name, content): |
| 11 | path = os.path.join(layouts_dir, name) |
| 12 |
+5
-5
| --- tests/test_translate.py | ||
| +++ tests/test_translate.py | ||
| @@ -9,12 +9,12 @@ | ||
| 9 | 9 | def test_translates_file_content(self): |
| 10 | 10 | with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f: |
| 11 | 11 | f.write("<p>Hello world</p>") |
| 12 | 12 | path = f.name |
| 13 | 13 | try: |
| 14 | - from utils.translate import translate | |
| 15 | - with patch("utils.translate.call_ai", return_value="<p>Hola mundo</p>") as mock_ai: | |
| 14 | + from hugoifier.utils.translate import translate | |
| 15 | + with patch("hugoifier.utils.translate.call_ai", return_value="<p>Hola mundo</p>") as mock_ai: | |
| 16 | 16 | result = translate(path, target_language="Spanish") |
| 17 | 17 | self.assertEqual(result, "<p>Hola mundo</p>") |
| 18 | 18 | call_args = mock_ai.call_args[0][0] |
| 19 | 19 | self.assertIn("Spanish", call_args) |
| 20 | 20 | self.assertIn("Hello world", call_args) |
| @@ -24,21 +24,21 @@ | ||
| 24 | 24 | def test_uses_target_language_param(self): |
| 25 | 25 | with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f: |
| 26 | 26 | f.write("<p>Bonjour</p>") |
| 27 | 27 | path = f.name |
| 28 | 28 | try: |
| 29 | - from utils.translate import translate | |
| 30 | - with patch("utils.translate.call_ai", return_value="<p>Hallo</p>") as mock_ai: | |
| 29 | + from hugoifier.utils.translate import translate | |
| 30 | + with patch("hugoifier.utils.translate.call_ai", return_value="<p>Hallo</p>") as mock_ai: | |
| 31 | 31 | translate(path, target_language="German") |
| 32 | 32 | call_args = mock_ai.call_args[0][0] |
| 33 | 33 | self.assertIn("German", call_args) |
| 34 | 34 | finally: |
| 35 | 35 | os.unlink(path) |
| 36 | 36 | |
| 37 | 37 | def test_returns_error_on_missing_file(self): |
| 38 | - from utils.translate import translate | |
| 38 | + from hugoifier.utils.translate import translate | |
| 39 | 39 | result = translate("/nonexistent/file.html") |
| 40 | 40 | self.assertIn("failed", result.lower()) |
| 41 | 41 | |
| 42 | 42 | |
| 43 | 43 | if __name__ == "__main__": |
| 44 | 44 | unittest.main() |
| 45 | 45 |
| --- tests/test_translate.py | |
| +++ tests/test_translate.py | |
| @@ -9,12 +9,12 @@ | |
| 9 | def test_translates_file_content(self): |
| 10 | with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f: |
| 11 | f.write("<p>Hello world</p>") |
| 12 | path = f.name |
| 13 | try: |
| 14 | from utils.translate import translate |
| 15 | with patch("utils.translate.call_ai", return_value="<p>Hola mundo</p>") as mock_ai: |
| 16 | result = translate(path, target_language="Spanish") |
| 17 | self.assertEqual(result, "<p>Hola mundo</p>") |
| 18 | call_args = mock_ai.call_args[0][0] |
| 19 | self.assertIn("Spanish", call_args) |
| 20 | self.assertIn("Hello world", call_args) |
| @@ -24,21 +24,21 @@ | |
| 24 | def test_uses_target_language_param(self): |
| 25 | with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f: |
| 26 | f.write("<p>Bonjour</p>") |
| 27 | path = f.name |
| 28 | try: |
| 29 | from utils.translate import translate |
| 30 | with patch("utils.translate.call_ai", return_value="<p>Hallo</p>") as mock_ai: |
| 31 | translate(path, target_language="German") |
| 32 | call_args = mock_ai.call_args[0][0] |
| 33 | self.assertIn("German", call_args) |
| 34 | finally: |
| 35 | os.unlink(path) |
| 36 | |
| 37 | def test_returns_error_on_missing_file(self): |
| 38 | from utils.translate import translate |
| 39 | result = translate("/nonexistent/file.html") |
| 40 | self.assertIn("failed", result.lower()) |
| 41 | |
| 42 | |
| 43 | if __name__ == "__main__": |
| 44 | unittest.main() |
| 45 |
| --- tests/test_translate.py | |
| +++ tests/test_translate.py | |
| @@ -9,12 +9,12 @@ | |
| 9 | def test_translates_file_content(self): |
| 10 | with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f: |
| 11 | f.write("<p>Hello world</p>") |
| 12 | path = f.name |
| 13 | try: |
| 14 | from hugoifier.utils.translate import translate |
| 15 | with patch("hugoifier.utils.translate.call_ai", return_value="<p>Hola mundo</p>") as mock_ai: |
| 16 | result = translate(path, target_language="Spanish") |
| 17 | self.assertEqual(result, "<p>Hola mundo</p>") |
| 18 | call_args = mock_ai.call_args[0][0] |
| 19 | self.assertIn("Spanish", call_args) |
| 20 | self.assertIn("Hello world", call_args) |
| @@ -24,21 +24,21 @@ | |
| 24 | def test_uses_target_language_param(self): |
| 25 | with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f: |
| 26 | f.write("<p>Bonjour</p>") |
| 27 | path = f.name |
| 28 | try: |
| 29 | from hugoifier.utils.translate import translate |
| 30 | with patch("hugoifier.utils.translate.call_ai", return_value="<p>Hallo</p>") as mock_ai: |
| 31 | translate(path, target_language="German") |
| 32 | call_args = mock_ai.call_args[0][0] |
| 33 | self.assertIn("German", call_args) |
| 34 | finally: |
| 35 | os.unlink(path) |
| 36 | |
| 37 | def test_returns_error_on_missing_file(self): |
| 38 | from hugoifier.utils.translate import translate |
| 39 | result = translate("/nonexistent/file.html") |
| 40 | self.assertIn("failed", result.lower()) |
| 41 | |
| 42 | |
| 43 | if __name__ == "__main__": |
| 44 | unittest.main() |
| 45 |