diff --git a/main.py b/main.py index a6922bc..761ea81 100644 --- a/main.py +++ b/main.py @@ -2,7 +2,9 @@ import http.server import random import shutil +from datetime import UTC, datetime from pathlib import Path +from xml.sax.saxutils import escape from jinja2 import Environment, FileSystemLoader from typer import Typer @@ -26,6 +28,29 @@ def load_settings() -> dict: return {} +def write_sitemap(site_url: str) -> None: + """Gera dist/sitemap.xml com as páginas renderizadas (referenciado no robots.txt).""" + lastmod = datetime.now(UTC).date().isoformat() + entries = [] + for page in sorted(output_folder.glob("*.html")): + path = "/" if page.name == "index.html" else f"/{page.name}" + priority = "1.0" if path == "/" else "0.8" + entries.append( + " \n" + f" {escape(site_url + path)}\n" + f" {lastmod}\n" + " weekly\n" + f" {priority}\n" + " " + ) + (output_folder / "sitemap.xml").write_text( + '\n' + '\n' + + "\n".join(entries) + + "\n\n" + ) + + @cli.command() def render(): """Gera os arquivos HTML estáticos na pasta dist.""" @@ -57,8 +82,24 @@ def render(): robots_file.write_text("User-agent: *\nDisallow: /\n") else: site_url = settings.get("event", {}).get("url", "").rstrip("/") - sitemap_line = f"\nSitemap: {site_url}/sitemap.xml" if site_url else "" - robots_file.write_text(f"User-agent: *\nAllow: /{sitemap_line}\n") + # CSS, JS e imagens liberados explicitamente: o Googlebot precisa deles + # para renderizar a página e avaliá-la corretamente (mobile-first). + robots_lines = [ + "User-agent: *", + "Allow: /", + "", + "User-agent: Googlebot", + "Allow: /", + "Allow: /static/", + "", + "User-agent: Googlebot-Image", + "Allow: /static/img/", + "", + ] + if site_url: + robots_lines.append(f"Sitemap: {site_url}/sitemap.xml") + write_sitemap(site_url) + robots_file.write_text("\n".join(robots_lines) + "\n") # Arquivo .nojekyll para o GitHub Pages servir arquivos estáticos diretamente (output_folder / ".nojekyll").touch() diff --git a/templates/base.j2 b/templates/base.j2 index cf98ac0..c3354ec 100644 --- a/templates/base.j2 +++ b/templates/base.j2 @@ -7,6 +7,10 @@ {% set event_desc = event.description if event and event.description else (event_description | default('')) %} {% set event_url = event.url if event and event.url else ('https://' ~ event_edition ~ '.pythonnordeste.org/') %} {% set event_preview_image = theme.preview_image if theme and theme.preview_image else (preview_image | default('static/img/logo_pyne2027.png')) %} +{# Redes sociais (Facebook, LinkedIn, WhatsApp, X) exigem URL absoluta na og:image #} +{% set event_preview_image_url = event_preview_image if event_preview_image.startswith('http') else (event_url.rstrip('/') ~ '/' ~ event_preview_image.lstrip('/')) %} +{% set event_preview_image_alt = theme.preview_image_alt if theme and theme.preview_image_alt else ('Logo oficial da ' ~ event_name ~ ' ' ~ event_edition) %} +{% set event_title = event_name ~ ' ' ~ event_edition ~ ((' | ' ~ event_city ~ ' - ' ~ event_state) if event_city else '') %} {% set event_favicon = theme.favicon if theme and theme.favicon else (favicon | default('static/img/favicon.png')) %} {% set is_indexing_allowed = (environment != 'staging') and (allow_indexing | default(true)) %} @@ -39,22 +43,31 @@ - {{ event_name }} {{ event_edition }}{% if event_city %} | {{ event_city }} - {{ event_state }}{% endif %} - - + {{ event_title }} + + + - - - + + {% if event_preview_image_url.startswith('https://') %} + + {% endif %} + {% if theme and theme.preview_image_width and theme.preview_image_height %} + + + {% endif %} + + - + + - + - - + + diff --git a/tests/test_site.py b/tests/test_site.py index 047eff9..1183cc2 100644 --- a/tests/test_site.py +++ b/tests/test_site.py @@ -1,5 +1,8 @@ from pathlib import Path +import pytest + +import main from main import load_settings, output_folder, render, settings_file @@ -21,7 +24,7 @@ def test_referenced_theme_assets_exist(): for key in ["logo", "favicon", "preview_image"]: asset_path = theme.get(key) - if asset_path: + if asset_path and not asset_path.startswith("http"): assert Path(asset_path).exists(), ( f"O arquivo de tema '{key}' apontado para '{asset_path}' não existe." ) @@ -40,27 +43,103 @@ def test_render_generates_dist_and_index_html(): assert "Pessoas > Tecnologia" in content -def test_staging_environment_blocks_search_engines(): +@pytest.fixture +def render_with(monkeypatch): + """Renderiza o site sobrescrevendo chaves do variables.yaml, sem alterar o arquivo.""" + + def _render(**overrides): + settings = {**load_settings(), **overrides} + monkeypatch.setattr(main, "load_settings", lambda: settings) + render() + return settings + + return _render + + +def test_render_generates_github_pages_files(): render() - robots_file = output_folder / "robots.txt" - assert robots_file.exists(), "O arquivo dist/robots.txt deve ser gerado." - assert "Disallow: /" in robots_file.read_text(), ( - "Em staging, o robots.txt deve bloquear todos os robôs de busca." + assert (output_folder / "robots.txt").exists(), ( + "O arquivo dist/robots.txt deve ser gerado." + ) + assert (output_folder / "favicon.ico").exists(), ( + "O arquivo dist/favicon.ico deve ser copiado." + ) + assert (output_folder / ".nojekyll").exists(), ( + "O arquivo dist/.nojekyll deve ser gerado para o GitHub Pages." + ) + + +@pytest.mark.parametrize( + "overrides", + [ + {"environment": "staging", "allow_indexing": True}, + {"environment": "production", "allow_indexing": False}, + ], + ids=["staging", "production-sem-indexacao"], +) +def test_indexing_disabled_blocks_search_engines(render_with, overrides): + render_with(**overrides) + + assert "Disallow: /" in (output_folder / "robots.txt").read_text(), ( + "Sem indexação, o robots.txt deve bloquear todos os robôs de busca." + ) + assert not (output_folder / "sitemap.xml").exists(), ( + "Sem indexação, o sitemap.xml não deve ser gerado." ) index_html = (output_folder / "index.html").read_text(encoding="utf-8") - assert 'content="noindex, nofollow' in index_html, ( - "Em staging, a meta tag robots deve ser 'noindex, nofollow'." + assert 'name="robots" content="noindex, nofollow' in index_html, ( + "Sem indexação, a meta tag robots deve ser 'noindex, nofollow'." ) assert 'name="googlebot" content="noindex, nofollow' in index_html, ( - "Em staging, a meta tag googlebot deve ser 'noindex, nofollow'." + "Sem indexação, a meta tag googlebot deve ser 'noindex, nofollow'." ) + assert 'rel="canonical"' not in index_html assert "googletagmanager.com/gtag/js" not in index_html, ( - "Em staging, o Google Analytics não deve ser injetado." + "Sem indexação, o Google Analytics não deve ser injetado." ) - assert (output_folder / "favicon.ico").exists(), ( - "O arquivo dist/favicon.ico deve ser copiado." + + +def test_production_allows_search_engines(render_with): + settings = render_with(environment="production", allow_indexing=True) + site_url = settings["event"]["url"].rstrip("/") + + robots = (output_folder / "robots.txt").read_text() + assert "Disallow: /" not in robots, ( + "Em produção, o robots.txt não deve bloquear os robôs de busca." ) - assert (output_folder / ".nojekyll").exists(), ( - "O arquivo dist/.nojekyll deve ser gerado para o GitHub Pages." + assert "Allow: /" in robots + assert f"Sitemap: {site_url}/sitemap.xml" in robots + + sitemap = output_folder / "sitemap.xml" + assert sitemap.exists(), "Em produção, o dist/sitemap.xml deve ser gerado." + assert f"{site_url}/" in sitemap.read_text() + + index_html = (output_folder / "index.html").read_text(encoding="utf-8") + assert 'name="robots" content="index, follow"' in index_html + assert 'name="googlebot" content="index, follow' in index_html + assert f'rel="canonical" href="{site_url}/"' in index_html + if settings.get("google_tag"): + assert f"gtag/js?id={settings['google_tag']}" in index_html, ( + "Em produção, o Google Analytics deve ser injetado." + ) + + +def test_social_preview_uses_absolute_image_url(): + settings = load_settings() + render() + site_url = settings["event"]["url"].rstrip("/") + preview_image = settings["theme"]["preview_image"] + expected = ( + preview_image + if preview_image.startswith("http") + else f"{site_url}/{preview_image.lstrip('/')}" + ) + + index_html = (output_folder / "index.html").read_text(encoding="utf-8") + assert f'property="og:image" content="{expected}"' in index_html, ( + "A og:image deve ser uma URL absoluta para os previews em redes sociais." ) + assert f'name="twitter:image" content="{expected}"' in index_html + assert 'property="og:title"' in index_html + assert 'name="twitter:card" content="summary_large_image"' in index_html diff --git a/variables.yaml b/variables.yaml index d9a7392..cc165f5 100644 --- a/variables.yaml +++ b/variables.yaml @@ -21,7 +21,13 @@ theme: text_dark: "#2B1B22" # Texto contrastante logo: "static/img/pyne_emblem.png" favicon: "static/img/favicon.png" + # Imagem do preview ao compartilhar o link em redes sociais (og:image / twitter:image). + # Aceita caminho relativo (convertido para URL absoluta usando event.url) ou URL completa. + # Recomendado: 1200x630 (ou 1920x1080), JPG/PNG, até 5MB. preview_image: "static/img/logo_pyne2027.png" + preview_image_alt: "Logo oficial da Python Nordeste 2027" + preview_image_width: 1920 + preview_image_height: 1080 # Links e Ações Principais (habilitados conforme a fase do evento) actions: @@ -52,6 +58,6 @@ google_tag: "G-Z6HYH6HCL2" # - Motores de busca (Google, Bing, etc.) são bloqueados com # - O arquivo robots.txt é gerado com "Disallow: /" # - O Google Analytics é pausado para não poluir as métricas reais -environment: "staging" # "staging" ou "production" -allow_indexing: false # Se false, impede indexação no Google / motores de busca +environment: "production" # "staging" ou "production" +allow_indexing: true # Se false, impede indexação no Google / motores de busca