refactor!: remove build pipeline, ship HTML directly from repo root

This commit is contained in:
2026-07-30 10:59:27 +02:00
parent 48fdd4a565
commit edaa4997a5
13 changed files with 39 additions and 1429 deletions
+1 -3
View File
@@ -1,7 +1,5 @@
node_modules/
dist/
.atl/
*.log
.DS_Store
.env
.env.local
.env.local
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 0.2.0 — 2026-07-30
- Removed build pipeline (no more Node.js, no more `node_modules`, no more `src`/`dist` split).
- `index.html`, `robots.txt`, `sitemap.xml` and `og-image.jpg` promoted to repo root.
- Coolify static site now points at repo root directly.
## 0.1.0 — 2026-07-30
- Publicación inicial del editorial estático sobre el Decreto 25/2018 y la evolución de la posidonia.
+2 -10
View File
@@ -6,19 +6,11 @@ Sitio editorial estático en español sobre el seguimiento de la *Posidonia ocea
## Build
Requiere Node.js 18 o posterior.
```sh
npm ci
npm run build
npm run check
```
Los cuatro artefactos de producción se generan en `dist/`. Para una vista local: `npm start`.
No build step required. The `index.html`, `robots.txt`, `sitemap.xml` and `og-image.jpg` in this repo root are deployed as-is.
## Deploy
En Coolify, configura un servicio de sitio estático con `npm ci && npm run build` como comando de construcción y `dist/` como directorio público. Crea un CNAME para `posidonia.eivissaenvironmentalwatch.es` apuntando al ingreso del servidor Hetzner/Coolify.
En Coolify, configura un servicio de sitio estático que apunte directamente al directorio raíz de este repositorio. Crea un CNAME para `posidonia.eivissaenvironmentalwatch.es` apuntando al ingreso del servidor Hetzner/Coolify.
Coolify aprovisiona HTTPS con Let's Encrypt después de que el DNS se propague. Si se alcanza un límite de emisión, conserva el DNS correcto, revisa los registros de validación y reintenta después de una hora; no solicites certificados repetidamente durante la propagación.
+18
View File
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

-1149
View File
File diff suppressed because it is too large Load Diff
-17
View File
@@ -1,17 +0,0 @@
{
"name": "posidonia",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": { "node": ">=18" },
"scripts": {
"build": "node scripts/build.mjs",
"check": "node scripts/check-contracts.mjs",
"start": "npx --yes serve dist/"
},
"devDependencies": {
"lightningcss": "^1.29.2",
"html-minifier-terser": "^7.2.0",
"sharp": "^0.35.3"
}
}
+3
View File
@@ -0,0 +1,3 @@
User-agent: *
Allow: /
Sitemap: https://posidonia.eivissaenvironmentalwatch.es/sitemap.xml
-74
View File
@@ -1,74 +0,0 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { gzipSync } from 'node:zlib';
import { transform } from 'lightningcss';
import { minify } from 'html-minifier-terser';
import sharp from 'sharp';
const root = new URL('../', import.meta.url);
const sourceUrl = new URL('src/index.html', root);
const distUrl = new URL('dist/', root);
const canonical = 'https://posidonia.eivissaenvironmentalwatch.es/';
const source = await readFile(sourceUrl, 'utf8');
const styleMatch = source.match(/<style>([\s\S]*?)<\/style>/i);
if (!styleMatch) throw new Error('No se encontró el bloque <style> en src/index.html');
const css = transform({
code: Buffer.from(styleMatch[1]),
minify: true,
targets: { chrome: 109 << 16, firefox: 115 << 16, safari: 16 << 16 }
}).code.toString();
const withMinifiedCss = source.replace(styleMatch[0], `<style>${css}</style>`);
const html = await minify(withMinifiedCss, {
collapseWhitespace: true,
conservativeCollapse: true,
removeComments: false,
removeRedundantAttributes: true,
removeEmptyAttributes: false,
sortAttributes: false,
sortClassName: false,
minifyCSS: false,
minifyJS: false
});
await mkdir(distUrl, { recursive: true });
await writeFile(new URL('index.html', distUrl), html);
const robots = `User-agent: *\nAllow: /\nSitemap: ${canonical}sitemap.xml\n`;
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n <url>\n <loc>${canonical}</loc>\n <lastmod>2026-07-30</lastmod>\n <changefreq>monthly</changefreq>\n <priority>1.0</priority>\n </url>\n</urlset>\n`;
await writeFile(new URL('robots.txt', distUrl), robots);
await writeFile(new URL('sitemap.xml', distUrl), sitemap);
const ogSvg = await readFile(new URL('scripts/og-image.svg', root));
let ogImage;
for (const quality of [68, 58, 48, 40, 32]) {
ogImage = await sharp(ogSvg).resize(1200, 630).jpeg({ quality, chromaSubsampling: '4:2:0', mozjpeg: true }).toBuffer();
if (ogImage.byteLength <= 21 * 1024) break;
}
await writeFile(new URL('og-image.jpg', distUrl), ogImage);
const files = {
'index.html': Buffer.from(html),
'robots.txt': Buffer.from(robots),
'sitemap.xml': Buffer.from(sitemap),
'og-image.jpg': ogImage
};
const htmlGzip = gzipSync(files['index.html']).byteLength;
const totalRaw = Object.values(files).reduce((sum, file) => sum + file.byteLength, 0);
const totalGzip = Object.values(files).reduce((sum, file) => sum + gzipSync(file).byteLength, 0);
console.log(`source HTML: ${Buffer.byteLength(source)} B raw / ${gzipSync(source).byteLength} B gzip`);
for (const [name, file] of Object.entries(files)) console.log(`${name}: ${file.byteLength} B raw / ${gzipSync(file).byteLength} B gzip`);
console.log(`total: ${totalRaw} B raw / ${totalGzip} B gzip`);
const failures = [];
if (htmlGzip >= 10 * 1024) failures.push(`HTML gzip ${htmlGzip} B excede el límite estricto de 10 KB`);
if (totalGzip >= 30 * 1024) failures.push(`Total gzip ${totalGzip} B excede el límite estricto de 30 KB`);
if (ogImage.byteLength > 30 * 1024) failures.push(`og-image.jpg ${ogImage.byteLength} B excede 30 KB`);
if (/<script\b[^>]*\bsrc=["']https?:\/\//i.test(html)) failures.push('Se encontró un script externo');
if (/@font-face/i.test(html)) failures.push('Se encontró una fuente web');
if (/<link\b[^>]*rel=["']stylesheet["'][^>]*href=/i.test(html)) failures.push('Se encontró una hoja de estilos externa');
if (failures.length) {
for (const failure of failures) console.error(`ERROR: ${failure}`);
process.exit(1);
}
-75
View File
@@ -1,75 +0,0 @@
import { readFile, stat } from 'node:fs/promises';
import { gzipSync } from 'node:zlib';
const root = new URL('../', import.meta.url);
const dist = new URL('dist/', root);
const html = await readFile(new URL('index.html', dist), 'utf8');
const failures = [];
const assert = (condition, message) => { if (!condition) failures.push(message); };
const htmlGzip = gzipSync(html).byteLength;
const assets = ['index.html', 'robots.txt', 'sitemap.xml', 'og-image.jpg'];
const buffers = await Promise.all(assets.map(name => readFile(new URL(name, dist))));
const totalGzip = buffers.reduce((sum, buffer) => sum + gzipSync(buffer).byteLength, 0);
assert(htmlGzip < 10 * 1024, `HTML gzip debe ser <10 KB; actual: ${htmlGzip} B`);
assert(totalGzip < 30 * 1024, `Total gzip debe ser <30 KB; actual: ${totalGzip} B`);
const jsonMatch = html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/i);
assert(Boolean(jsonMatch), 'Falta JSON-LD NewsArticle');
if (jsonMatch) {
try {
const data = JSON.parse(jsonMatch[1]);
assert(data['@type'] === 'NewsArticle', 'JSON-LD @type debe ser NewsArticle');
for (const field of ['headline', 'datePublished', 'dateModified', 'description', 'author', 'publisher', 'mainEntityOfPage', 'keywords', 'inLanguage', 'isBasedOn']) {
assert(data[field] !== undefined && data[field] !== '', `JSON-LD requiere ${field}`);
}
assert(Array.isArray(data.author) && data.author.length === 2, 'JSON-LD requiere dos organizaciones autoras');
assert(data.publisher?.['@type'] === 'Person', 'JSON-LD publisher debe ser Person');
} catch (error) {
failures.push(`JSON-LD no es JSON válido: ${error.message}`);
}
}
const title = html.match(/<title>([\s\S]*?)<\/title>/i)?.[1] ?? '';
const description = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/i)?.[1] ?? '';
assert(title.length > 0 && title.length <= 60, `El título debe tener ≤60 caracteres; actual: ${title.length}`);
assert(description.length >= 150 && description.length <= 160, `La descripción debe tener 150160 caracteres; actual: ${description.length}`);
assert(/<html\b[^>]*lang=["']es["']/i.test(html), 'Falta <html lang="es">');
assert(!/<script\b[^>]*\bsrc=["']https?:\/\//i.test(html), 'No se permiten scripts externos');
assert(!/@font-face/i.test(html), 'No se permiten fuentes web');
assert(!/<link\b[^>]*rel=["']stylesheet["'][^>]*href=/i.test(html), 'No se permiten hojas de estilo externas');
assert(/--muted:\s*#5C5A4E/i.test(html), '--muted debe ser #5C5A4E exactamente');
assert(/--ink-dim:\s*#4A4839/i.test(html), 'Falta el token --ink-dim #4A4839');
for (const id of ['chartBaleares', 'chartTalamanca']) {
const chart = html.match(new RegExp(`<svg\\b[^>]*id=["']${id}["'][^>]*>[\\s\\S]*?<\\/svg>`, 'i'))?.[0];
assert(Boolean(chart), `Falta SVG #${id}`);
if (chart) {
const bytes = Buffer.byteLength(chart);
assert(bytes < 2048, `SVG #${id} debe ser <2 KB; actual: ${bytes} B`);
assert(/<title\b/i.test(chart) && /<desc\b/i.test(chart), `SVG #${id} requiere title y desc`);
assert(/role=["']img["']/i.test(chart) && /aria-(?:label|labelledby)=/i.test(chart), `SVG #${id} requiere rol y nombre accesible`);
}
}
const sectionIds = ['que-es', 'marco-legal', 'datos-archipielago', 'datos-eivissa', 'por-que', 'voz-local', 'transparencia'];
for (const id of sectionIds) assert(new RegExp(`<section\\b[^>]*id=["']${id}["'][^>]*aria-labelledby=`, 'i').test(html), `Sección #${id} requiere aria-labelledby`);
assert((html.match(/<section\b/gi) ?? []).length === 7, 'La página debe tener exactamente 7 secciones');
assert((html.match(/<h1\b/gi) ?? []).length === 1, 'La página debe tener exactamente un h1');
for (const prohibited of ['Itziar Zameza', 'itziarzameza.dev', 'Nota de edición', 'cdnjs']) assert(!html.includes(prohibited), `Cadena prohibida: ${prohibited}`);
assert(!/<(?:div|section)[^>]*(?:id|class)=["'][^"']*map/i.test(html), 'No se permite una sección de mapa');
assert(!/<form\b/i.test(html), 'No se permite formulario de contacto');
const robots = await readFile(new URL('robots.txt', dist), 'utf8');
const sitemap = await readFile(new URL('sitemap.xml', dist), 'utf8');
assert(robots === 'User-agent: *\nAllow: /\nSitemap: https://posidonia.eivissaenvironmentalwatch.es/sitemap.xml\n', 'robots.txt no coincide con el contrato');
assert((sitemap.match(/<url>/g) ?? []).length === 1 && sitemap.includes('<loc>https://posidonia.eivissaenvironmentalwatch.es/</loc>'), 'sitemap.xml debe contener solo la URL canónica');
const og = await stat(new URL('og-image.jpg', dist));
assert(og.size <= 30 * 1024, `og-image.jpg debe ser ≤30 KB; actual: ${og.size} B`);
assert(buffers[3][0] === 0xff && buffers[3][1] === 0xd8 && buffers[3].at(-2) === 0xff && buffers[3].at(-1) === 0xd9, 'og-image.jpg no es un JPEG válido');
if (failures.length) {
for (const failure of failures) console.error(`FAIL: ${failure}`);
process.exit(1);
}
console.log(`OK: contratos cumplidos — HTML ${htmlGzip} B gzip, total ${totalGzip} B gzip, título ${title.length}, descripción ${description.length}`);
-13
View File
@@ -1,13 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<defs>
<linearGradient id="sea" x1="0" y1="0" x2="1" y2="1">
<stop stop-color="#123028"/>
<stop offset="1" stop-color="#1F4A3D"/>
</linearGradient>
</defs>
<rect width="1200" height="630" fill="url(#sea)"/>
<path d="M0 470C250 390 430 560 690 470s350-70 510-30v190H0z" fill="#4C7A67" opacity=".45"/>
<text x="90" y="205" fill="#ECE6D8" font-family="system-ui,-apple-system,sans-serif" font-size="42" font-weight="600">8 años del decreto de posidonia</text>
<text x="90" y="305" fill="#fff" font-family="Georgia,serif" font-size="68">Baleares mejora · Eivissa no</text>
<text x="90" y="555" fill="#ECE6D8" font-family="ui-monospace,monospace" font-size="25">Posidonia · eivissaenvironmentalwatch.es</text>
</svg>

Before

Width:  |  Height:  |  Size: 839 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://posidonia.eivissaenvironmentalwatch.es/</loc>
<lastmod>2026-07-30</lastmod>
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>
</urlset>
-88
View File
File diff suppressed because one or more lines are too long