feat(infra): migración a tema Apollo, reestructuración del blog y página 'Sobre mí'
All checks were successful
Zola / build-and-deploy (push) Successful in 11s

- Implementado tema Apollo como base visual.
- Segregación de contenido: Artículos movidos a /blog.
- Creación de identidad: Nueva página 'Sobre mí' (about.md).
- CI/CD: Actualizado workflow para soporte de submódulos recursivos.
- UX: Ajustes en homepage para listado de últimos posts.
This commit is contained in:
Alejandro Guerrero 2025-12-14 19:05:54 +01:00
parent 0bbe34e8da
commit 4fb49961b4
Signed by: alejandrogs73
GPG key ID: 1CFF10953BEE333C
115 changed files with 6580 additions and 72 deletions

103
public/js/codeblock.js Normal file
View file

@ -0,0 +1,103 @@
const successIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="bi bi-check-lg" viewBox="0 0 16 16">
<path d="M13.485 1.85a.5.5 0 0 1 1.065.02.75.75 0 0 1-.02 1.065L5.82 12.78a.75.75 0 0 1-1.106.02L1.476 9.346a.75.75 0 1 1 1.05-1.07l2.74 2.742L12.44 2.92a.75.75 0 0 1 1.045-.07z"/>
</svg>`;
const errorIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="bi bi-x-lg" viewBox="0 0 16 16">
<path d="M2.293 2.293a1 1 0 0 1 1.414 0L8 6.586l4.293-4.293a1 1 0 0 1 1.414 1.414L9.414 8l4.293 4.293a1 1 0 0 1-1.414 1.414L8 9.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L6.586 8 2.293 3.707a1 1 0 0 1 0-1.414z"/>
</svg>`;
const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="bi bi-clipboard" viewBox="0 0 16 16">
<path d="M10 1.5a.5.5 0 0 1 .5-.5h2a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h2a.5.5 0 0 1 .5.5V3h3V1.5zM6.5 3V2h3v1h-3zm4 0v1h2a1 1 0 0 0-1-1h-2V3zm-5 0H3a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H5.5V3z"/>
</svg>`;
// Function to change icons after copying
const changeIcon = (button, isSuccess) => {
button.innerHTML = isSuccess ? successIcon : errorIcon;
setTimeout(() => {
button.innerHTML = copyIcon; // Reset to copy icon
}, 2000);
};
// Function to get code text from tables, skipping line numbers
const getCodeFromTable = (codeBlock) => {
return [...codeBlock.querySelectorAll('tr')]
.map(row => row.querySelector('td:last-child')?.innerText ?? '')
.join('');
};
// Function to get code text from non-table blocks
const getNonTableCode = (codeBlock) => {
return codeBlock.textContent.trim();
};
document.addEventListener('DOMContentLoaded', function () {
// Select all `pre` elements containing `code`
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const pre = entry.target.parentNode;
const clipboardBtn = pre.querySelector('.clipboard-button');
const label = pre.querySelector('.code-label');
if (clipboardBtn) {
// Adjust the position of the clipboard button when the `code` is not fully visible
clipboardBtn.style.right = entry.isIntersecting ? '5px' : `-${entry.boundingClientRect.right - pre.clientWidth + 5}px`;
}
if (label) {
// Adjust the position of the label similarly
label.style.right = entry.isIntersecting ? '0px' : `-${entry.boundingClientRect.right - pre.clientWidth}px`;
}
});
}, {
root: null, // observing relative to viewport
rootMargin: '0px',
threshold: 1.0 // Adjust this to control when the callback fires
});
document.querySelectorAll('pre code').forEach(codeBlock => {
const pre = codeBlock.parentNode;
pre.style.position = 'relative'; // Ensure parent `pre` can contain absolute elements
// Create and append the copy button
const copyBtn = document.createElement('button');
copyBtn.className = 'clipboard-button';
copyBtn.innerHTML = copyIcon;
copyBtn.setAttribute('aria-label', 'Copy code to clipboard');
pre.appendChild(copyBtn);
// Attach event listener to copy button
copyBtn.addEventListener('click', async () => {
// Determine if the code is in a table or not
const isTable = codeBlock.querySelector('table');
const codeToCopy = isTable ? getCodeFromTable(codeBlock) : getNonTableCode(codeBlock);
try {
await navigator.clipboard.writeText(codeToCopy);
changeIcon(copyBtn, true); // Show success icon
} catch (error) {
console.error('Failed to copy text: ', error);
changeIcon(copyBtn, false); // Show error icon
}
});
const langClass = codeBlock.className.match(/language-(\w+)/);
const lang = langClass ? langClass[1] : 'default';
// Create and append the label
const label = document.createElement('span');
label.className = 'code-label label-' + lang; // Use the specific language class
label.textContent = lang.toUpperCase(); // Display the language as label
pre.appendChild(label);
let ticking = false;
pre.addEventListener('scroll', () => {
if (!ticking) {
window.requestAnimationFrame(() => {
copyBtn.style.right = `-${pre.scrollLeft}px`;
label.style.right = `-${pre.scrollLeft}px`;
ticking = false;
});
ticking = true;
}
});
});
});

271
public/js/count.js Normal file
View file

@ -0,0 +1,271 @@
// GoatCounter: https://www.goatcounter.com
// This file (and *only* this file) is released under the ISC license: https://opensource.org/licenses/ISC
;(function() {
'use strict';
if (window.goatcounter && window.goatcounter.vars) // Compatibility with very old version; do not use.
window.goatcounter = window.goatcounter.vars
else
window.goatcounter = window.goatcounter || {}
// Load settings from data-goatcounter-settings.
var s = document.querySelector('script[data-goatcounter]')
if (s && s.dataset.goatcounterSettings) {
try { var set = JSON.parse(s.dataset.goatcounterSettings) }
catch (err) { console.error('invalid JSON in data-goatcounter-settings: ' + err) }
for (var k in set)
if (['no_onload', 'no_events', 'allow_local', 'allow_frame', 'path', 'title', 'referrer', 'event'].indexOf(k) > -1)
window.goatcounter[k] = set[k]
}
var enc = encodeURIComponent
// Get all data we're going to send off to the counter endpoint.
var get_data = function(vars) {
var data = {
p: (vars.path === undefined ? goatcounter.path : vars.path),
r: (vars.referrer === undefined ? goatcounter.referrer : vars.referrer),
t: (vars.title === undefined ? goatcounter.title : vars.title),
e: !!(vars.event || goatcounter.event),
s: [window.screen.width, window.screen.height, (window.devicePixelRatio || 1)],
b: is_bot(),
q: location.search,
}
var rcb, pcb, tcb // Save callbacks to apply later.
if (typeof(data.r) === 'function') rcb = data.r
if (typeof(data.t) === 'function') tcb = data.t
if (typeof(data.p) === 'function') pcb = data.p
if (is_empty(data.r)) data.r = document.referrer
if (is_empty(data.t)) data.t = document.title
if (is_empty(data.p)) data.p = get_path()
if (rcb) data.r = rcb(data.r)
if (tcb) data.t = tcb(data.t)
if (pcb) data.p = pcb(data.p)
return data
}
// Check if a value is "empty" for the purpose of get_data().
var is_empty = function(v) { return v === null || v === undefined || typeof(v) === 'function' }
// See if this looks like a bot; there is some additional filtering on the
// backend, but these properties can't be fetched from there.
var is_bot = function() {
// Headless browsers are probably a bot.
var w = window, d = document
if (w.callPhantom || w._phantom || w.phantom)
return 150
if (w.__nightmare)
return 151
if (d.__selenium_unwrapped || d.__webdriver_evaluate || d.__driver_evaluate)
return 152
if (navigator.webdriver)
return 153
return 0
}
// Object to urlencoded string, starting with a ?.
var urlencode = function(obj) {
var p = []
for (var k in obj)
if (obj[k] !== '' && obj[k] !== null && obj[k] !== undefined && obj[k] !== false)
p.push(enc(k) + '=' + enc(obj[k]))
return '?' + p.join('&')
}
// Show a warning in the console.
var warn = function(msg) {
if (console && 'warn' in console)
console.warn('goatcounter: ' + msg)
}
// Get the endpoint to send requests to.
var get_endpoint = function() {
var s = document.querySelector('script[data-goatcounter]')
if (s && s.dataset.goatcounter)
return s.dataset.goatcounter
return (goatcounter.endpoint || window.counter) // counter is for compat; don't use.
}
// Get current path.
var get_path = function() {
var loc = location,
c = document.querySelector('link[rel="canonical"][href]')
if (c) { // May be relative or point to different domain.
var a = document.createElement('a')
a.href = c.href
if (a.hostname.replace(/^www\./, '') === location.hostname.replace(/^www\./, ''))
loc = a
}
return (loc.pathname + loc.search) || '/'
}
// Run function after DOM is loaded.
var on_load = function(f) {
if (document.body === null)
document.addEventListener('DOMContentLoaded', function() { f() }, false)
else
f()
}
// Filter some requests that we (probably) don't want to count.
goatcounter.filter = function() {
if ('visibilityState' in document && document.visibilityState === 'prerender')
return 'visibilityState'
if (!goatcounter.allow_frame && location !== parent.location)
return 'frame'
if (!goatcounter.allow_local && location.hostname.match(/(localhost$|^127\.|^10\.|^172\.(1[6-9]|2[0-9]|3[0-1])\.|^192\.168\.|^0\.0\.0\.0$)/))
return 'localhost'
if (!goatcounter.allow_local && location.protocol === 'file:')
return 'localfile'
if (localStorage && localStorage.getItem('skipgc') === 't')
return 'disabled with #toggle-goatcounter'
return false
}
// Get URL to send to GoatCounter.
window.goatcounter.url = function(vars) {
var data = get_data(vars || {})
if (data.p === null) // null from user callback.
return
data.rnd = Math.random().toString(36).substr(2, 5) // Browsers don't always listen to Cache-Control.
var endpoint = get_endpoint()
if (!endpoint)
return warn('no endpoint found')
return endpoint + urlencode(data)
}
// Count a hit.
window.goatcounter.count = function(vars) {
var f = goatcounter.filter()
if (f)
return warn('not counting because of: ' + f)
var url = goatcounter.url(vars)
if (!url)
return warn('not counting because path callback returned null')
if (!navigator.sendBeacon(url)) {
// This mostly fails due to being blocked by CSP; try again with an
// image-based fallback.
var img = document.createElement('img')
img.src = url
img.style.position = 'absolute' // Affect layout less.
img.style.bottom = '0px'
img.style.width = '1px'
img.style.height = '1px'
img.loading = 'eager'
img.setAttribute('alt', '')
img.setAttribute('aria-hidden', 'true')
var rm = function() { if (img && img.parentNode) img.parentNode.removeChild(img) }
img.addEventListener('load', rm, false)
document.body.appendChild(img)
}
}
// Get a query parameter.
window.goatcounter.get_query = function(name) {
var s = location.search.substr(1).split('&')
for (var i = 0; i < s.length; i++)
if (s[i].toLowerCase().indexOf(name.toLowerCase() + '=') === 0)
return s[i].substr(name.length + 1)
}
// Track click events.
window.goatcounter.bind_events = function() {
if (!document.querySelectorAll) // Just in case someone uses an ancient browser.
return
var send = function(elem) {
return function() {
goatcounter.count({
event: true,
path: (elem.dataset.goatcounterClick || elem.name || elem.id || ''),
title: (elem.dataset.goatcounterTitle || elem.title || (elem.innerHTML || '').substr(0, 200) || ''),
referrer: (elem.dataset.goatcounterReferrer || elem.dataset.goatcounterReferral || ''),
})
}
}
Array.prototype.slice.call(document.querySelectorAll("*[data-goatcounter-click]")).forEach(function(elem) {
if (elem.dataset.goatcounterBound)
return
var f = send(elem)
elem.addEventListener('click', f, false)
elem.addEventListener('auxclick', f, false) // Middle click.
elem.dataset.goatcounterBound = 'true'
})
}
// Add a "visitor counter" frame or image.
window.goatcounter.visit_count = function(opt) {
on_load(function() {
opt = opt || {}
opt.type = opt.type || 'html'
opt.append = opt.append || 'body'
opt.path = opt.path || get_path()
opt.attr = opt.attr || {width: '200', height: (opt.no_branding ? '60' : '80')}
opt.attr['src'] = get_endpoint() + 'er/' + enc(opt.path) + '.' + enc(opt.type) + '?'
if (opt.no_branding) opt.attr['src'] += '&no_branding=1'
if (opt.style) opt.attr['src'] += '&style=' + enc(opt.style)
if (opt.start) opt.attr['src'] += '&start=' + enc(opt.start)
if (opt.end) opt.attr['src'] += '&end=' + enc(opt.end)
var tag = {png: 'img', svg: 'img', html: 'iframe'}[opt.type]
if (!tag)
return warn('visit_count: unknown type: ' + opt.type)
if (opt.type === 'html') {
opt.attr['frameborder'] = '0'
opt.attr['scrolling'] = 'no'
}
var d = document.createElement(tag)
for (var k in opt.attr)
d.setAttribute(k, opt.attr[k])
var p = document.querySelector(opt.append)
if (!p)
return warn('visit_count: append not found: ' + opt.append)
p.appendChild(d)
})
}
// Make it easy to skip your own views.
if (location.hash === '#toggle-goatcounter') {
if (localStorage.getItem('skipgc') === 't') {
localStorage.removeItem('skipgc', 't')
alert('GoatCounter tracking is now ENABLED in this browser.')
}
else {
localStorage.setItem('skipgc', 't')
alert('GoatCounter tracking is now DISABLED in this browser until ' + location + ' is loaded again.')
}
}
if (!goatcounter.no_onload)
on_load(function() {
// 1. Page is visible, count request.
// 2. Page is not yet visible; wait until it switches to 'visible' and count.
// See #487
if (!('visibilityState' in document) || document.visibilityState === 'visible')
goatcounter.count()
else {
var f = function(e) {
if (document.visibilityState !== 'visible')
return
document.removeEventListener('visibilitychange', f)
goatcounter.count()
}
document.addEventListener('visibilitychange', f)
}
if (!goatcounter.no_events)
goatcounter.bind_events()
})
})();

2
public/js/imamu.js Normal file
View file

@ -0,0 +1,2 @@
// https://cloud.umami.is/script.js
!function(){"use strict";(t=>{const{screen:{width:e,height:a},navigator:{language:r},location:n,document:i,history:c}=t,{hostname:s,href:o,origin:u}=n,{currentScript:l,referrer:d}=i,h=o.startsWith("data:")?void 0:t.localStorage;if(!l)return;const m="data-",f=l.getAttribute.bind(l),p=f(m+"website-id"),g=f(m+"host-url"),y=f(m+"tag"),b="false"!==f(m+"auto-track"),v="true"===f(m+"exclude-search"),w=f(m+"domains")||"",S=w.split(",").map((t=>t.trim())),N=`${(g||"https://api-gateway.umami.dev"||l.src.split("/").slice(0,-1).join("/")).replace(/\/$/,"")}/api/send`,T=`${e}x${a}`,A=/data-umami-event-([\w-_]+)/,x=m+"umami-event",O=300,U=t=>{if(t){try{const e=decodeURI(t);if(e!==t)return e}catch(e){return t}return encodeURI(t)}},j=t=>{try{const{pathname:e,search:a,hash:r}=new URL(t,n.href);t=e+a+r}catch(t){}return v?t.split("?")[0]:t},k=()=>({website:p,hostname:s,screen:T,language:r,title:U(q),url:U(W),referrer:U(_),tag:y||void 0}),E=(t,e,a)=>{a&&(_=W,W=j(a.toString()),W!==_&&setTimeout(K,O))},L=()=>!p||h&&h.getItem("umami.disabled")||w&&!S.includes(s),$=async(t,e="event")=>{if(L())return;const a={"Content-Type":"application/json"};void 0!==B&&(a["x-umami-cache"]=B);try{const r=await fetch(N,{method:"POST",body:JSON.stringify({type:e,payload:t}),headers:a}),n=await r.text();return B=n}catch(t){}},I=()=>{D||(K(),(()=>{const t=(t,e,a)=>{const r=t[e];return(...e)=>(a.apply(null,e),r.apply(t,e))};c.pushState=t(c,"pushState",E),c.replaceState=t(c,"replaceState",E)})(),(()=>{const t=new MutationObserver((([t])=>{q=t&&t.target?t.target.text:void 0})),e=i.querySelector("head > title");e&&t.observe(e,{subtree:!0,characterData:!0,childList:!0})})(),i.addEventListener("click",(async t=>{const e=t=>["BUTTON","A"].includes(t),a=async t=>{const e=t.getAttribute.bind(t),a=e(x);if(a){const r={};return t.getAttributeNames().forEach((t=>{const a=t.match(A);a&&(r[a[1]]=e(t))})),K(a,r)}},r=t.target,i=e(r.tagName)?r:((t,a)=>{let r=t;for(let t=0;t<a;t++){if(e(r.tagName))return r;if(r=r.parentElement,!r)return null}})(r,10);if(!i)return a(r);{const{href:e,target:r}=i,c=i.getAttribute(x);if(c)if("A"===i.tagName){const s="_blank"===r||t.ctrlKey||t.shiftKey||t.metaKey||t.button&&1===t.button;if(c&&e)return s||t.preventDefault(),a(i).then((()=>{s||(n.href=e)}))}else if("BUTTON"===i.tagName)return a(i)}}),!0),D=!0)},K=(t,e)=>$("string"==typeof t?{...k(),name:t,data:"object"==typeof e?e:void 0}:"object"==typeof t?t:"function"==typeof t?t(k()):k()),R=t=>$({...k(),data:t},"identify");t.umami||(t.umami={track:K,identify:R});let B,D,W=j(o),_=d.startsWith(u)?"":d,q=i.title;b&&!L()&&("complete"===i.readyState?I():i.addEventListener("readystatechange",I,!0))})(window)}();

25
public/js/main.js Normal file
View file

@ -0,0 +1,25 @@
mmdElements = document.getElementsByClassName("mermaid");
const mmdHTML = [];
for (let i = 0; i < mmdElements.length; i++) {
mmdHTML[i] = mmdElements[i].innerHTML;
}
function mermaidRender(theme) {
if (theme == "dark") {
initOptions = {
startOnLoad: false,
theme: "dark",
};
} else {
initOptions = {
startOnLoad: false,
theme: "neutral",
};
}
for (let i = 0; i < mmdElements.length; i++) {
delete mmdElements[i].dataset.processed;
mmdElements[i].innerHTML = mmdHTML[i];
}
mermaid.initialize(initOptions);
mermaid.run();
}

1760
public/js/mermaid.js Normal file

File diff suppressed because one or more lines are too long

14
public/js/note.js Normal file
View file

@ -0,0 +1,14 @@
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.note-toggle').forEach(function(toggleButton) {
var content = toggleButton.nextElementSibling;
var isHidden = content.style.display === 'none';
toggleButton.setAttribute('aria-expanded', !isHidden);
toggleButton.addEventListener('click', function() {
var expanded = this.getAttribute('aria-expanded') === 'true';
this.setAttribute('aria-expanded', !expanded);
content.style.display = expanded ? 'none' : 'block';
});
});
});

File diff suppressed because it is too large Load diff

1
public/js/searchElasticlunr.min.js vendored Normal file

File diff suppressed because one or more lines are too long

82
public/js/themetoggle.js Normal file
View file

@ -0,0 +1,82 @@
function setTheme(mode) {
localStorage.setItem("theme-storage", mode);
}
// Functions needed for the theme toggle
//
function toggleTheme() {
const currentTheme = getSavedTheme();
if (currentTheme === "light") {
setTheme("dark");
updateItemToggleTheme();
} else if (currentTheme === "dark") {
setTheme("auto");
updateItemToggleTheme();
} else {
setTheme("light");
updateItemToggleTheme();
}
}
function updateItemToggleTheme() {
let mode = getSavedTheme();
const darkModeStyle = document.getElementById("darkModeStyle");
if (darkModeStyle) {
if (mode === "dark" || (mode === "auto" && getSystemPrefersDark())) {
darkModeStyle.disabled = false;
} else {
darkModeStyle.disabled = true;
}
}
const sunIcon = document.getElementById("sun-icon");
const moonIcon = document.getElementById("moon-icon");
const autoIcon = document.getElementById("auto-icon");
if (sunIcon && moonIcon && autoIcon) {
sunIcon.style.display = (mode === "light") ? "block" : "none";
moonIcon.style.display = (mode === "dark") ? "block" : "none";
autoIcon.style.display = (mode === "auto") ? "block" : "none";
if (mode === "auto") {
autoIcon.style.filter = getSystemPrefersDark() ? "invert(1)" : "invert(0)";
} else {
autoIcon.style.filter = "none";
}
}
let htmlElement = document.querySelector("html");
if (mode === "dark" || (mode === "auto" && getSystemPrefersDark())) {
htmlElement.classList.remove("light")
htmlElement.classList.add("dark")
} else {
htmlElement.classList.remove("dark")
htmlElement.classList.add("light")
}
}
function getSystemPrefersDark() {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function getSavedTheme() {
let currentTheme = localStorage.getItem("theme-storage");
if(!currentTheme) {
currentTheme = getSystemPrefersDark() ? "dark" : "light";
}
return currentTheme;
}
// Update the toggle theme on page load
updateItemToggleTheme();
// Listen for system theme changes in auto mode
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
if (getSavedTheme() === "auto") {
updateItemToggleTheme();
}
});
}

49
public/js/toc.js Normal file
View file

@ -0,0 +1,49 @@
document.addEventListener("DOMContentLoaded", () => {
let observer = new IntersectionObserver(handler, {
threshold: [0],
});
let paragraphs = [...document.querySelectorAll("section > *")];
let submenu = [...document.querySelectorAll(".toc a")];
function previousHeaderId(e) {
for (; e && !e.matches("h1, h2, h3, h4"); ) e = e.previousElementSibling;
return e?.id;
}
let paragraphMenuMap = paragraphs.reduce((e, t) => {
let n = previousHeaderId(t);
if (((t.previousHeader = n), n)) {
let t = submenu.find((e) => decodeURIComponent(e.hash) === "#" + n);
e[n] = t;
}
return e;
}, {});
paragraphs.forEach((e) => observer.observe(e));
let selection;
function handler(e) {
selection = (selection || e).map(
(t) => e.find((e) => e.target === t.target) || t,
);
for (s of selection)
s.isIntersecting ||
paragraphMenuMap[
s.target.previousHeader
]?.parentElement.classList.remove("selected", "parent");
for (s of selection)
if (s.isIntersecting) {
let e = paragraphMenuMap[s.target.previousHeader]?.closest("li");
if ((e?.classList.add("selected"), e === void 0)) continue;
// Find the anchor element within the list item
let t = e.querySelector("a");
if (t) {
t.scrollIntoView({
block: "nearest",
inline: "nearest",
});
}
for (; e; ) {
e?.classList.add("parent"), (e = e.parentElement.closest("li"));
}
}
}
});