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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
" <url>\n"
f" <loc>{escape(site_url + path)}</loc>\n"
f" <lastmod>{lastmod}</lastmod>\n"
" <changefreq>weekly</changefreq>\n"
f" <priority>{priority}</priority>\n"
" </url>"
)
(output_folder / "sitemap.xml").write_text(
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
+ "\n".join(entries)
+ "\n</urlset>\n"
)


@cli.command()
def render():
"""Gera os arquivos HTML estáticos na pasta dist."""
Expand Down Expand Up @@ -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()
Expand Down
33 changes: 23 additions & 10 deletions templates/base.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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)) %}
<head>
Expand Down Expand Up @@ -39,22 +43,31 @@

<meta name="author" content="Comunidade {{ event_name }}">

<title>{{ event_name }} {{ event_edition }}{% if event_city %} | {{ event_city }} - {{ event_state }}{% endif %}</title>

<meta property="og:title" content="{{ event_name }} {{ event_edition }}{% if event_city %} | {{ event_city }} - {{ event_state }}{% endif %}">
<title>{{ event_title }}</title>

<!-- Open Graph: preview de links no Facebook, LinkedIn, WhatsApp, Telegram, etc. -->
<meta property="og:title" content="{{ event_title }}">
<meta property="og:description" content="{{ event_desc }}">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ event_url }}">
<meta property="og:image" content="{{ event_preview_image }}">
<meta property="og:image:alt" content="Logo oficial {{ event_name }} {{ event_edition }}">
<meta property="og:site_name" content="{{ event_name }} {{ event_edition }}{% if event_city %} | {{ event_city }} - {{ event_state }}{% endif %}">
<meta property="og:image" content="{{ event_preview_image_url }}">
{% if event_preview_image_url.startswith('https://') %}
<meta property="og:image:secure_url" content="{{ event_preview_image_url }}">
{% endif %}
{% if theme and theme.preview_image_width and theme.preview_image_height %}
<meta property="og:image:width" content="{{ theme.preview_image_width }}">
<meta property="og:image:height" content="{{ theme.preview_image_height }}">
{% endif %}
<meta property="og:image:alt" content="{{ event_preview_image_alt }}">
<meta property="og:site_name" content="{{ event_title }}">
<meta property="og:locale" content="pt_BR">


<!-- Twitter / X Cards -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ event_name }} {{ event_edition }}{% if event_city %} | {{ event_city }} - {{ event_state }}{% endif %}">
<meta name="twitter:title" content="{{ event_title }}">
<meta name="twitter:description" content="{{ event_desc }}">
<meta name="twitter:image" content="{{ event_preview_image }}">
<meta name="twitter:image:alt" content="Logo oficial {{ event_name }} {{ event_edition }}">
<meta name="twitter:image" content="{{ event_preview_image_url }}">
<meta name="twitter:image:alt" content="{{ event_preview_image_alt }}">

<link rel="icon" href="{{ event_favicon }}" type="image/png">
<link rel="apple-touch-icon" href="static/img/apple-touch-icon.png">
Expand Down
107 changes: 93 additions & 14 deletions tests/test_site.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from pathlib import Path

import pytest

import main
from main import load_settings, output_folder, render, settings_file


Expand All @@ -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."
)
Expand All @@ -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"<loc>{site_url}/</loc>" 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
10 changes: 8 additions & 2 deletions variables.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -52,6 +58,6 @@ google_tag: "G-Z6HYH6HCL2"
# - Motores de busca (Google, Bing, etc.) são bloqueados com <meta name="robots" content="noindex, nofollow">
# - 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

Loading