# Sitios estáticos

Si ya tienes el sitio hecho —a mano, con un generador como Astro, Vite o Hugo, o con un asistente—, lo subes tal cual y Meteor lo aloja en `https://<slug>.metsite.co`. Cada subida es una **versión** que no cambia; volver atrás es poner en línea una versión anterior, sin subir nada otra vez.

Son tres pasos: **crear el sitio** una vez → **subir una versión** cada vez que cambie → **activarla** (por defecto pasa sola al subirla).

## Antes de empezar

| Scope | Para qué | Con una key de test |
|---|---|---|
| `sites:write` | Crear el sitio, subir versiones y activarlas. | No: responde `403` `test_mode_restricted`, porque publica en internet. |
| `sites:read` | Listar las versiones. | Sí |

Las rutas son planas (`/sites/…`): operan sobre el workspace de la key, y un sitio de otro workspace responde `404`.

## 1. Crear el sitio

```ts
const sitio = await met.sites.createStatic({ name: 'Neuroliderazgo', slug: 'neuroliderazgo' });
// { site_id: 812, slug: 'neuroliderazgo', url: 'https://neuroliderazgo.metsite.co' }
```
```python
sitio = met.sites.create_static(name="Neuroliderazgo", slug="neuroliderazgo")
```
```bash
curl -X POST https://api.met.meteor.com.co/api/v1/sites/static \
  -H "Authorization: Bearer $METEOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Neuroliderazgo","slug":"neuroliderazgo"}'
```

- `slug` es el subdominio: de 3 a 40 caracteres, minúsculas, números y guiones sueltos (sin dos seguidos ni al principio o al final). Es **único entre todos los sitios de Meteor**: si está tomado responde `409` `conflict`, y algunos están reservados por la plataforma (`400`).
- El sitio **nace sin publicar**: `url` responde cuando haya una versión activa.

## 2. Subir una versión

Desde un zip, la forma más simple cuando ya tienes la carpeta construida (hasta 50 MB):

```ts
import { readFile } from 'node:fs/promises';

const zip = await readFile('sitio.zip');
const version = await met.sites.deployZip(sitio.site_id, zip);
// { id: '3f6c1e0a-…', status: 'ready', source: 'zip', file_count: 42, total_bytes: 5242880, is_active: true, url: 'https://neuroliderazgo.metsite.co' }
```
```python
with open("sitio.zip", "rb") as f:
    version = met.sites.deploy_zip(sitio["site_id"], f.read())
```
```bash
cd dist && zip -r ../sitio.zip . && cd ..
curl -X POST https://api.met.meteor.com.co/api/v1/sites/812/deploys \
  -H "Authorization: Bearer $METEOR_API_KEY" \
  -F file=@sitio.zip
```

O archivo por archivo, en JSON: `content` en `utf8` para el texto y en `base64` para lo binario. El cuerpo entero tiene un tope de 10 MB; un sitio más grande va por zip.

```ts
import { readdir, readFile } from 'node:fs/promises';
import { join, relative, extname } from 'node:path';

const TEXTO = new Set(['.html', '.css', '.js', '.mjs', '.json', '.svg', '.txt', '.xml', '.webmanifest', '.map']);

async function archivosDe(carpeta: string, raiz = carpeta) {
  const out: { path: string; content: string; encoding: 'utf8' | 'base64' }[] = [];
  for (const entrada of await readdir(carpeta, { withFileTypes: true })) {
    const ruta = join(carpeta, entrada.name);
    if (entrada.isDirectory()) out.push(...(await archivosDe(ruta, raiz)));
    else {
      const texto = TEXTO.has(extname(entrada.name));
      const bytes = await readFile(ruta);
      out.push({
        path: relative(raiz, ruta).split('\\').join('/'),
        content: texto ? bytes.toString('utf8') : bytes.toString('base64'),
        encoding: texto ? 'utf8' : 'base64',
      });
    }
  }
  return out;
}

const version = await met.sites.deployFiles(812, { files: await archivosDe('dist') });
```
```python
import base64, pathlib

TEXTO = {".html", ".css", ".js", ".mjs", ".json", ".svg", ".txt", ".xml", ".webmanifest", ".map"}
raiz = pathlib.Path("dist")
files = []
for p in raiz.rglob("*"):
    if p.is_file():
        es_texto = p.suffix in TEXTO
        files.append({
            "path": p.relative_to(raiz).as_posix(),
            "content": p.read_text("utf-8") if es_texto else base64.b64encode(p.read_bytes()).decode(),
            "encoding": "utf8" if es_texto else "base64",
        })

version = met.sites.deploy_files(812, files=files)
```

Cada llamada es una **versión completa**: no se suman archivos a la anterior. Con `activate: false` (`activate=False` en Python) la versión se sube sin ponerla en línea, para revisarla o publicarla después.

### Qué se acepta

| Regla | Tope |
|---|---|
| Zip | 50 MB |
| Archivos por versión | 2000 |
| Un archivo, descomprimido | 25 MB |
| Toda la versión, descomprimida | 200 MB |
| JSON de `deployFiles` | 10 MB |
| Largo de una ruta | 512 caracteres |

- Extensiones: `html`, `htm`, `css`, `js`, `mjs`, `map`, `json`, `txt`, `xml`, `svg`, `png`, `jpg`, `jpeg`, `gif`, `webp`, `avif`, `ico`, `woff`, `woff2`, `ttf`, `otf`, `eot`, `mp4`, `webm`, `mp3`, `pdf` y `webmanifest`.
- Las rutas van sin `/` al principio, sin `..`, sin `\` y sin carpetas ocultas (salvo `.well-known/`). Cada parte de la ruta admite letras sin tilde, números, espacios y `. _ - @ + ( ) ! , = & $ '`; una tilde o una `ñ` la rechaza. Lo más seguro: minúsculas, números, `-`, `_` y `.`.
- Si todo viene dentro de una sola carpeta (`dist/`), se quita: `dist/index.html` queda como `index.html`.
- `.DS_Store` y `__MACOSX/` se ignoran.
- **Basta una ruta inválida para que no se suba nada.** Responde `400` `deploy_files_rejected` con la lista de rechazados en `errors`, cada uno con su `path` y su `reason`.

### Cómo se sirven las rutas

- `/` sirve `index.html`.
- `/contacto` sirve `contacto.html` o `contacto/index.html`.
- Una ruta que no existe sirve tu `404.html`, con status 404.
- El `robots.txt` y el `sitemap.xml` que traiga tu sitio se sirven tal cual.

## Versiones y vuelta atrás

```ts
const versiones = await met.sites.listDeploys(812, { limit: 10 });
// de la más reciente a la más vieja; is_active: true en la que está en línea

const anterior = versiones.find((v) => !v.is_active && v.status === 'ready');
if (anterior) await met.sites.activate(812, anterior.id);
```
```python
versiones = met.sites.list_deploys(812, limit=10)
anterior = next((v for v in versiones if not v["is_active"] and v["status"] == "ready"), None)
if anterior:
    met.sites.activate(812, anterior["id"])
```
```bash
curl -X POST https://api.met.meteor.com.co/api/v1/sites/812/deploys/3f6c1e0a-5b7d-4c2e-9a1f-0d8b7e6a5c4b/activate \
  -H "Authorization: Bearer $METEOR_API_KEY"
```

| `status` | Qué es |
|---|---|
| `ready` | Subida completa: se puede activar. |
| `uploading` | Todavía se está subiendo. |
| `failed` | La subida no terminó; `error` dice por qué. Sube otra versión. |

`listDeploys` trae 20 por defecto (de 1 a 100). `activate` solo pone en línea una versión `ready` de **ese** sitio; la primera activación publica el sitio. El cambio es inmediato y no se sube nada otra vez.

## Tu dominio

El sitio vive en `https://<slug>.metsite.co`. Conectar un dominio propio (`www.tunegocio.com`) no se hace por la API.

## Desde un asistente (MCP)

Estas operaciones se alcanzan con la herramienta genérica del servidor MCP (`met_call_operation`), en su forma por archivos: el zip no viaja por MCP, porque un asistente no adjunta binarios en la conversación.

## Errores comunes

| Código | Qué pasó |
|---|---|
| `400` `validation_error` | El `slug` no cumple el formato, o está reservado. |
| `400` `deploy_files_rejected` | Alguna ruta o extensión no se acepta; mira `errors`. No se subió nada. |
| `400` `deploy_zip_missing` | El zip no llegó en el campo multipart `file`. |
| `400` `zip_invalid` | El archivo no es un zip válido. |
| `400` `deploy_empty` · `deploy_too_many_files` · `deploy_too_large` · `deploy_zip_too_large` | La versión no trae archivos o pasa uno de los topes. |
| `413` `FILE_TOO_LARGE` | El zip subido por multipart pasa de 50 MB. |
| `403` `test_mode_restricted` | Una key `met_test_` no publica sitios. |
| `404` `not_found` · `deploy_not_found` | El sitio no es de este workspace, o la versión no es de este sitio. |
| `409` `conflict` | El `slug` ya está en uso. |
| `409` `site_not_static` | El sitio se arma por bloques en el panel: las versiones son solo para sitios creados con `createStatic`. |
| `409` `deploy_not_ready` | Se intentó activar una versión que no está `ready`. |
| `503` `deploy_upload_failed` | La subida falló de nuestro lado. La versión queda `failed` y la que estaba en línea sigue en línea: vuelve a subirla. |
