Comunicados en el HUB por cuenta, fuera el generador
This commit is contained in:
parent
7ed0276704
commit
7de6a0ada9
8 changed files with 6 additions and 411 deletions
|
|
@ -3,9 +3,9 @@
|
|||
Web de la acampada del 26S en Madrid. HTML y CSS, sin JavaScript.
|
||||
|
||||
1. `site/` va a `/var/www/acampada26s.net`. `deploy/nginx.conf` y `deploy/hub.conf` a nginx.
|
||||
2. Directo es el HUB del pub OASIS (`/c`), servido en local en `127.0.0.1:3000`.
|
||||
3. Comunicados, comisiones y debates salen de OASIS con `deploy/web26s.timer`. Rellenar `CUENTA` y `TRIBU` en `deploy/web26s.service`.
|
||||
4. Publica quien está en la tribu pública de la acampada y lo sigue la cuenta de la acampada. Comunicado: post con `#comunicado26s`, primera línea de título. Comisión: subtribu pública. Debate: foro público.
|
||||
2. `/c` es el HUB del pub OASIS, en `127.0.0.1:3000` de la misma máquina.
|
||||
3. Directo: `/c?type=feed`. Comunicados: `/c/inhabitant/CUENTA_ACAMPADA?type=posts`.
|
||||
4. Cambiar `CUENTA_ACAMPADA` en `site/index.html` por la id de la cuenta de la acampada codificada (`@` → `%40`, `/` → `%2F`, `=` → `%3D`). Esa cuenta tiene que tener activada la visibilidad clearnet en OASIS y el pub tiene que seguirla.
|
||||
5. Fotos subidas a mano: `tools/limpia-img.sh` antes, siempre.
|
||||
|
||||
Código AGPL-3.0. Textos CC BY-SA 4.0. Tipografía Anton, OFL.
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
[Unit]
|
||||
Description=acampada26s: comunicados, comisiones y debates desde OASIS
|
||||
After=oasis.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=oasis
|
||||
Environment=OASIS_DIR=/home/oasis/oasis
|
||||
Environment=CUENTA=
|
||||
Environment=TRIBU=
|
||||
Environment=WEB=/var/www/acampada26s.net
|
||||
ExecStart=/bin/sh -c 'set -e; node /opt/26s/tools/extrae.js > /tmp/26s.json; python3 /opt/26s/tools/genera.py "$WEB" < /tmp/26s.json'
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/www/acampada26s.net
|
||||
PrivateTmp=yes
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
[Unit]
|
||||
Description=acampada26s: actualizar cada 2 minutos
|
||||
|
||||
[Timer]
|
||||
OnBootSec=1min
|
||||
OnUnitActiveSec=2min
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
|
|
@ -57,7 +57,6 @@ h3 { font-size: 26px; margin: 0 0 6px; }
|
|||
}
|
||||
.cuadro { font-size: 30px; padding: 28px 12px; }
|
||||
.bt { font-size: 20px; padding: 12px; }
|
||||
.volver { display: inline-block; margin-top: 24px; }
|
||||
|
||||
.cuadro:hover, .cuadro:focus-visible,
|
||||
.bt:hover, .bt:focus-visible {
|
||||
|
|
@ -67,21 +66,6 @@ h3 { font-size: 26px; margin: 0 0 6px; }
|
|||
outline: none;
|
||||
}
|
||||
|
||||
.lista { list-style: none; margin: 0; padding: 0; }
|
||||
.lista > li { border-bottom: 1px solid var(--fg); padding: 20px 0; }
|
||||
.lista a:hover, .lista a:focus-visible { color: var(--acc); }
|
||||
time { font-family: var(--tit); font-size: 18px; }
|
||||
.borrador { border: var(--bd); padding: 0 6px; font-family: var(--tit); font-size: 14px; }
|
||||
|
||||
article { max-width: 720px; }
|
||||
article p { margin: 0 0 1em; }
|
||||
|
||||
code, pre {
|
||||
font: 14px/1.4 ui-monospace, "DejaVu Sans Mono", monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
pre { border: var(--bd); padding: 12px; white-space: pre-wrap; margin: 0 0 1em; }
|
||||
|
||||
footer { margin: 64px 0 24px; border-top: var(--bd); padding-top: 12px; font-size: 14px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
|
@ -100,4 +84,3 @@ footer { margin: 64px 0 24px; border-top: var(--bd); padding-top: 12px; font-siz
|
|||
.colectivos a { font-family: var(--tit); font-size: 22px; text-transform: uppercase; text-decoration: none; }
|
||||
.colectivos a:hover, .colectivos a:focus-visible { color: var(--acc); }
|
||||
.colectivos span { display: block; font-size: 14px; }
|
||||
.lista strong { font-family: var(--tit); font-weight: 400; font-size: 22px; text-transform: uppercase; }
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
<nav aria-label="Principal">
|
||||
<ul class="cuadros">
|
||||
<li><a class="cuadro" href="/c?type=feed">Directo</a></li>
|
||||
<li><a class="cuadro" href="#comunicados">Comunicados</a></li>
|
||||
<li><a class="cuadro" href="/c/inhabitant/CUENTA_ACAMPADA?type=posts">Comunicados</a></li>
|
||||
<li><a class="cuadro" href="#organizate">Organízate</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
|
@ -29,13 +29,6 @@
|
|||
</header>
|
||||
|
||||
<main class="w">
|
||||
<section id="comunicados">
|
||||
<h2>Comunicados</h2>
|
||||
<!-- comunicados:inicio -->
|
||||
<p>Todavía no hay comunicados.</p>
|
||||
<!-- comunicados:fin -->
|
||||
</section>
|
||||
|
||||
<section id="organizate">
|
||||
<h2>Organízate</h2>
|
||||
<p>La acampada se organiza en <a href="/c">OASIS</a>, una red social libre sobre Secure Scuttlebutt. No hay empresa detrás ni cuentas que se puedan cerrar desde fuera. Al instalarla entras solo en La Plaza, el pub común, y desde ahí llegas a la acampada.</p>
|
||||
|
|
@ -44,17 +37,8 @@
|
|||
<li>Busca la tribu <strong>Acampada 26S</strong>.</li>
|
||||
<li>Entra en la comisión que te toque y en los debates.</li>
|
||||
</ol>
|
||||
<p><a href="/c?type=feed">Directo</a> es lo que la acampada va subiendo al pub. Se ve sin instalar nada.</p>
|
||||
|
||||
<h3>Comisiones</h3>
|
||||
<!-- comisiones:inicio -->
|
||||
<p>Las comisiones se crean como tribus dentro de OASIS.</p>
|
||||
<!-- comisiones:fin -->
|
||||
|
||||
<h3>Debates</h3>
|
||||
<!-- debates:inicio -->
|
||||
<p>Los debates se abren como foros dentro de OASIS.</p>
|
||||
<!-- debates:fin -->
|
||||
<p><a href="/c?type=feed">Directo</a> y <a href="/c/inhabitant/CUENTA_ACAMPADA?type=posts">comunicados</a> se ven sin instalar nada.</p>
|
||||
<p>Las comisiones son tribus y los debates son foros. Se entra desde la app.</p>
|
||||
</section>
|
||||
|
||||
<section id="colectivos">
|
||||
|
|
|
|||
143
tools/extrae.js
143
tools/extrae.js
|
|
@ -1,143 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
const path = require('path');
|
||||
|
||||
const dir = process.env.OASIS_DIR || path.join(process.env.HOME, 'oasis');
|
||||
const CUENTA = process.env.CUENTA;
|
||||
const TRIBU = process.env.TRIBU;
|
||||
const ETIQUETA = 'comunicado26s';
|
||||
|
||||
if (!CUENTA || !CUENTA.startsWith('@') || !TRIBU || !TRIBU.startsWith('%')) {
|
||||
console.error('faltan CUENTA=@... y TRIBU=%...');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ssbConfig = require(path.join(dir, 'src', 'server', 'ssb_config'));
|
||||
const ssbClient = require(path.join(dir, 'src', 'server', 'node_modules', 'ssb-client'));
|
||||
|
||||
const ESTRUCTURA = ['title', 'description', 'image', 'location', 'tags', 'isAnonymous',
|
||||
'inviteMode', 'status', 'parentTribeId', 'mapUrl'];
|
||||
|
||||
const todos = (src) => new Promise((res, rej) => {
|
||||
const out = [];
|
||||
const leer = () => src(null, (end, m) => {
|
||||
if (end === true) return res(out);
|
||||
if (end) return rej(end);
|
||||
out.push(m);
|
||||
leer();
|
||||
});
|
||||
leer();
|
||||
});
|
||||
|
||||
const lista = (x) => (Array.isArray(x) ? x : []);
|
||||
const igual = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
|
||||
function puntaTribu(raiz, msgs) {
|
||||
const porPadre = new Map();
|
||||
for (const m of msgs) {
|
||||
const r = m.value.content.replaces;
|
||||
if (!r) continue;
|
||||
if (!porPadre.has(r)) porPadre.set(r, []);
|
||||
porPadre.get(r).push(m);
|
||||
}
|
||||
const autorRaiz = raiz.value.author;
|
||||
let punta = raiz;
|
||||
for (;;) {
|
||||
const prev = punta.value.content;
|
||||
const validos = lista(porPadre.get(punta.key)).filter((m) => {
|
||||
const c = m.value.content;
|
||||
const a = m.value.author;
|
||||
if (a === autorRaiz) return true;
|
||||
const antes = lista(prev.members);
|
||||
const despues = lista(c.members);
|
||||
if (!antes.includes(a) && !despues.includes(a)) return false;
|
||||
const altas = despues.filter((x) => !antes.includes(x));
|
||||
const bajas = antes.filter((x) => !despues.includes(x));
|
||||
const delta = altas.length + bajas.length === 0 ||
|
||||
(altas.length === 1 && bajas.length === 0 && altas[0] === a) ||
|
||||
(bajas.length === 1 && altas.length === 0 && bajas[0] === a);
|
||||
if (!delta) return false;
|
||||
const inv = new Set(lista(prev.invites).map((i) => i && i.codeHash));
|
||||
if (lista(c.invites).some((i) => i && i.codeHash && !inv.has(i.codeHash))) return false;
|
||||
return ESTRUCTURA.every((f) => igual(prev[f], c[f]));
|
||||
});
|
||||
if (!validos.length) return punta;
|
||||
validos.sort((x, y) =>
|
||||
(y.value.author === autorRaiz) - (x.value.author === autorRaiz) ||
|
||||
x.value.timestamp - y.value.timestamp ||
|
||||
(x.key < y.key ? -1 : 1));
|
||||
punta = validos[0];
|
||||
}
|
||||
}
|
||||
|
||||
function imagenes(texto) {
|
||||
const re = /!\[[^\]]*\]\((&[A-Za-z0-9+/=]+\.sha256)\)/g;
|
||||
return [...texto.matchAll(re)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
const pub = (ssbConfig.keys.public || '').replace('.ed25519', '');
|
||||
const remote = `unix:${path.join(ssbConfig.path, 'socket')}~noauth:${pub}`;
|
||||
|
||||
ssbClient(ssbConfig.keys, { remote, caps: ssbConfig.caps }).then(async (ssb) => {
|
||||
const tipo = (type) => todos(ssb.messagesByType({ type }));
|
||||
const [contactos, tribus, posts, feeds, foros, lapidas] = await Promise.all(
|
||||
['contact', 'tribe', 'post', 'feed', 'forum', 'tombstone'].map(tipo));
|
||||
|
||||
const sigue = new Map();
|
||||
for (const m of contactos) {
|
||||
const c = m.value.content;
|
||||
if (m.value.author !== CUENTA || typeof c.contact !== 'string') continue;
|
||||
if (typeof c.following === 'boolean') sigue.set(c.contact, c.following && !c.blocking);
|
||||
else if (c.blocking) sigue.set(c.contact, false);
|
||||
}
|
||||
|
||||
const raiz = tribus.find((m) => m.key === TRIBU);
|
||||
if (!raiz || raiz.value.author !== CUENTA || raiz.value.content.isAnonymous !== false) {
|
||||
throw new Error('TRIBU no existe, no es pública o no la creó CUENTA');
|
||||
}
|
||||
const deTribu = (r) => tribus.filter((m) => m.value.content.replaces).concat([r]);
|
||||
const miembros = lista(puntaTribu(raiz, deTribu(raiz)).value.content.members);
|
||||
const editores = new Set([CUENTA, ...miembros.filter((id) => sigue.get(id) === true)]);
|
||||
|
||||
const borrado = new Set(lapidas
|
||||
.filter((m) => typeof m.value.content.target === 'string')
|
||||
.map((m) => `${m.value.author} ${m.value.content.target}`));
|
||||
const vivo = (m) => !borrado.has(`${m.value.author} ${m.key}`);
|
||||
const deEditor = (m) => editores.has(m.value.author) && vivo(m);
|
||||
|
||||
const lleva = (c) => (typeof c.text === 'string' &&
|
||||
c.text.toLowerCase().includes(`#${ETIQUETA}`)) ||
|
||||
lista(c.tags).some((t) => String(t).toLowerCase().replace(/^#/, '') === ETIQUETA);
|
||||
|
||||
const comunicados = posts.concat(feeds)
|
||||
.filter((m) => deEditor(m) && !m.value.content.root && lleva(m.value.content))
|
||||
.map((m) => ({
|
||||
key: m.key,
|
||||
autor: m.value.author,
|
||||
ts: m.value.timestamp,
|
||||
texto: m.value.content.text,
|
||||
imgs: imagenes(m.value.content.text),
|
||||
}));
|
||||
|
||||
const comisiones = tribus
|
||||
.filter((m) => !m.value.content.replaces && m.value.content.parentTribeId === TRIBU &&
|
||||
m.value.content.isAnonymous === false && deEditor(m))
|
||||
.map((r) => {
|
||||
const c = puntaTribu(r, deTribu(r)).value.content;
|
||||
return { key: r.key, ts: r.value.timestamp, titulo: c.title || '', texto: c.description || '' };
|
||||
});
|
||||
|
||||
const debates = foros
|
||||
.filter((m) => typeof m.value.content === 'object' && !m.value.content.root &&
|
||||
!m.value.content.isPrivate && deEditor(m))
|
||||
.map((m) => ({
|
||||
key: m.key,
|
||||
ts: m.value.timestamp,
|
||||
titulo: m.value.content.title || '',
|
||||
categoria: m.value.content.category || '',
|
||||
}));
|
||||
|
||||
ssb.close(() => console.log(JSON.stringify({ comunicados, comisiones, debates })));
|
||||
}).catch((e) => {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
177
tools/genera.py
177
tools/genera.py
|
|
@ -1,177 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
TZ = ZoneInfo("Europe/Madrid")
|
||||
ETIQUETA = re.compile(r"#comunicado26s\b", re.I)
|
||||
IMG = re.compile(r"!\[[^\]]*\]\((&[A-Za-z0-9+/=]+\.sha256)\)")
|
||||
ENLACE = re.compile(r"\[([^\]]+)\]\((https?://[^\s)]+)\)")
|
||||
NOMBRE = re.compile(r"^\d{4}-\d{2}-\d{2}-[0-9a-f]{8}\.html$")
|
||||
EXTRACTO = 240
|
||||
|
||||
|
||||
def fecha(ts):
|
||||
return datetime.fromtimestamp(ts / 1000, tz=TZ)
|
||||
|
||||
|
||||
def blob(ref):
|
||||
return "/c/blob/" + quote(ref, safe="")
|
||||
|
||||
|
||||
def plano(texto):
|
||||
texto = IMG.sub("", texto)
|
||||
texto = ENLACE.sub(r"\1", texto)
|
||||
texto = ETIQUETA.sub("", texto)
|
||||
return " ".join(texto.split())
|
||||
|
||||
|
||||
def partir(texto):
|
||||
lineas = ETIQUETA.sub("", texto).replace("\r", "").split("\n")
|
||||
for i, l in enumerate(lineas):
|
||||
t = plano(l).lstrip("# ").strip()
|
||||
if t:
|
||||
return t[:120], "\n".join(lineas[i + 1:])
|
||||
return "Comunicado", ""
|
||||
|
||||
|
||||
def cuerpo(texto):
|
||||
out = []
|
||||
for parrafo in re.split(r"\n\s*\n", texto.replace("\r", "").strip()):
|
||||
if not parrafo.strip():
|
||||
continue
|
||||
trozos, pos = [], 0
|
||||
for m in re.finditer(f"{IMG.pattern}|{ENLACE.pattern}", parrafo):
|
||||
trozos.append(html.escape(parrafo[pos:m.start()]))
|
||||
if m.group(1):
|
||||
trozos.append(f'<img src="{html.escape(blob(m.group(1)))}" alt="" loading="lazy">')
|
||||
else:
|
||||
trozos.append(f'<a href="{html.escape(m.group(3))}" rel="noopener noreferrer">'
|
||||
f"{html.escape(m.group(2))}</a>")
|
||||
pos = m.end()
|
||||
trozos.append(html.escape(parrafo[pos:]))
|
||||
p = "".join(trozos).strip().replace("\n", "<br>\n")
|
||||
out.append(f" <p>{p}</p>")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def nombre(c):
|
||||
h = hashlib.sha256(c["key"].encode()).hexdigest()[:8]
|
||||
return f"{fecha(c['ts']):%Y-%m-%d}-{h}.html"
|
||||
|
||||
|
||||
def cambia(doc, clave, contenido):
|
||||
ini, fin = f"<!-- {clave}:inicio -->", f"<!-- {clave}:fin -->"
|
||||
i, j = doc.find(ini), doc.find(fin)
|
||||
if i < 0 or j < i:
|
||||
sys.exit(f"faltan las marcas {clave}:inicio / {clave}:fin")
|
||||
return doc[:i + len(ini)] + "\n" + contenido + "\n " + doc[j:]
|
||||
|
||||
|
||||
def escribe(ruta, texto):
|
||||
try:
|
||||
with open(ruta, encoding="utf-8") as f:
|
||||
if f.read() == texto:
|
||||
return
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(ruta), prefix=".tmp.")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(texto)
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, ruta)
|
||||
except BaseException:
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
|
||||
|
||||
def lista_comunicados(cs):
|
||||
if not cs:
|
||||
return " <p>Todavía no hay comunicados.</p>"
|
||||
li = []
|
||||
for c in cs:
|
||||
t = fecha(c["ts"])
|
||||
titulo, resto = partir(c["texto"])
|
||||
ext = plano(resto)
|
||||
if len(ext) > EXTRACTO:
|
||||
ext = ext[:EXTRACTO].rsplit(" ", 1)[0] + "…"
|
||||
li.append(
|
||||
" <li>\n"
|
||||
f' <time datetime="{t:%Y-%m-%d}">{t:%d.%m.%Y}</time>\n'
|
||||
f' <h3><a href="/comunicados/{nombre(c)}">{html.escape(titulo)}</a></h3>\n'
|
||||
+ (f" <p>{html.escape(ext)}</p>\n" if ext else "")
|
||||
+ " </li>")
|
||||
return ' <ol class="lista">\n' + "\n".join(li) + "\n </ol>"
|
||||
|
||||
|
||||
def lista_simple(items, vacio, sub):
|
||||
if not items:
|
||||
return f" <p>{vacio}</p>"
|
||||
li = []
|
||||
for it in items:
|
||||
s = sub(it)
|
||||
li.append(f" <li><strong>{html.escape(it['titulo'])}</strong>"
|
||||
+ (f" · {html.escape(s)}" if s else "") + "</li>")
|
||||
return ' <ul class="lista">\n' + "\n".join(li) + "\n </ul>"
|
||||
|
||||
|
||||
def pagina(plantilla, c):
|
||||
t = fecha(c["ts"])
|
||||
titulo, resto = partir(c["texto"])
|
||||
art = (
|
||||
"<article>\n"
|
||||
f" <h1>{html.escape(titulo)}</h1>\n"
|
||||
f' <p><time datetime="{t.isoformat(timespec="minutes")}">{t:%d.%m.%Y %H:%M}</time></p>\n'
|
||||
f"{cuerpo(resto)}\n"
|
||||
" </article>")
|
||||
doc = cambia(plantilla, "articulo", " " + art)
|
||||
return doc.replace("<title>Comunicado · Acampada 26S</title>",
|
||||
f"<title>{html.escape(titulo)} · Acampada 26S</title>")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
sys.exit("uso: node tools/extrae.js | genera.py SITE")
|
||||
site = sys.argv[1]
|
||||
datos = json.load(sys.stdin)
|
||||
cs = sorted(datos.get("comunicados", []), key=lambda c: c["ts"], reverse=True)
|
||||
com = sorted(datos.get("comisiones", []), key=lambda c: c["titulo"].lower())
|
||||
deb = sorted(datos.get("debates", []), key=lambda c: c["ts"], reverse=True)
|
||||
|
||||
idx = os.path.join(site, "index.html")
|
||||
with open(idx, encoding="utf-8") as f:
|
||||
doc = f.read()
|
||||
doc = cambia(doc, "comunicados", lista_comunicados(cs))
|
||||
doc = cambia(doc, "comisiones", lista_simple(
|
||||
com, "Las comisiones se crean como tribus dentro de OASIS.",
|
||||
lambda c: plano(c["texto"])[:EXTRACTO]))
|
||||
doc = cambia(doc, "debates", lista_simple(
|
||||
deb, "Los debates se abren como foros dentro de OASIS.",
|
||||
lambda d: d["categoria"]))
|
||||
escribe(idx, doc)
|
||||
|
||||
dcom = os.path.join(site, "comunicados")
|
||||
os.makedirs(dcom, exist_ok=True)
|
||||
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "plantilla.html"),
|
||||
encoding="utf-8") as f:
|
||||
plantilla = f.read()
|
||||
quedan = set()
|
||||
for c in cs:
|
||||
n = nombre(c)
|
||||
quedan.add(n)
|
||||
escribe(os.path.join(dcom, n), pagina(plantilla, c))
|
||||
for n in os.listdir(dcom):
|
||||
if NOMBRE.match(n) and n not in quedan:
|
||||
os.unlink(os.path.join(dcom, n))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self'; img-src 'self'; font-src 'self'; base-uri 'none'; form-action 'none'">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title>Comunicado · Acampada 26S</title>
|
||||
<meta name="description" content="Comunicados, directo y organización de la acampada del 26S en Madrid.">
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="preload" href="/fonts/anton.woff2" as="font" type="font/woff2" crossorigin>
|
||||
</head>
|
||||
<body>
|
||||
<header class="w">
|
||||
<a class="bt volver" href="/">Acampada 26S</a>
|
||||
</header>
|
||||
|
||||
<main class="w">
|
||||
<!-- articulo:inicio -->
|
||||
<!-- articulo:fin -->
|
||||
</main>
|
||||
|
||||
<footer class="w">
|
||||
Textos CC BY-SA 4.0 · Código AGPL-3.0 · <a href="https://gitea.laenre.net/hacklab/26s" rel="noopener noreferrer">Fuente</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue