feat: add gh-dashboard project, language switch partial, header alignment fix
- Add gh-dashboard (debba/gh-dashboard) as collaboration project in EN/ES CV data - Add head-language-switch.html template partial with test - Change CV header text-align-last from justify to left - Update .gitignore to exclude prompt symlinks and cv-site binary
This commit is contained in:
@@ -69,3 +69,9 @@ tests/screenshots/
|
||||
|
||||
# Personal learning documentation README (private goals and notes)
|
||||
_go-learning/README.md
|
||||
|
||||
# Personal prompt symlinks (Obsidian vault)
|
||||
*-PROMPT.md
|
||||
|
||||
# Built binary
|
||||
cv-site
|
||||
|
||||
@@ -760,6 +760,37 @@
|
||||
],
|
||||
"projectID": "cdc-starter-kit"
|
||||
},
|
||||
{
|
||||
"title": "gh-dashboard - Self-Hosted GitHub Analytics Dashboard",
|
||||
"category": "collaboration",
|
||||
"projectName": "gh-dashboard",
|
||||
"projectDesc": "Self-Hosted GitHub Analytics Dashboard",
|
||||
"url": "https://github.com/debba/gh-dashboard",
|
||||
"gitRepoUrl": "https://github.com/debba/gh-dashboard",
|
||||
"openSource": true,
|
||||
"projectLogo": "",
|
||||
"location": "Online",
|
||||
"startDate": "2026",
|
||||
"current": true,
|
||||
"technologies": [
|
||||
"TypeScript",
|
||||
"React 19",
|
||||
"Vite 8",
|
||||
"Node.js",
|
||||
"GitHub API",
|
||||
"Vitest",
|
||||
"Self-hosted"
|
||||
],
|
||||
"shortDescription": "Open-source contributor to <strong><a href='https://github.com/debba/gh-dashboard' target='_blank' rel='noopener noreferrer'>gh-dashboard</a></strong> by Andrea Debernardi — a self-hosted GitHub analytics dashboard with repo health scores, cross-repo issue triage, daily digests, and Kanban boards. Deployed as my personal homepage at <strong><a href='https://github.txeo.club' target='_blank' rel='noopener noreferrer'>github.txeo.club</a></strong>.",
|
||||
"responsibilities": [
|
||||
"Contributed 2 merged PRs (750+ lines, 21 tests) improving UX, persistence, and responsive design",
|
||||
"Implemented localStorage caching for stats and filters — eliminated flash-of-zeros on page refresh",
|
||||
"Fixed CSS grid responsive breakpoints and built icon-only mobile tabs with smooth hover expansion",
|
||||
"Added user identity: GitHub avatar in top bar, org badges, favicon, and org-first sort order",
|
||||
"Deployed and maintain a live instance on personal VPS with GitHub OAuth and SSL"
|
||||
],
|
||||
"projectID": "gh-dashboard"
|
||||
},
|
||||
{
|
||||
"title": "Third Party Contributions",
|
||||
"category": "contrib",
|
||||
|
||||
@@ -760,6 +760,37 @@
|
||||
],
|
||||
"projectID": "cdc-starter-kit"
|
||||
},
|
||||
{
|
||||
"title": "gh-dashboard - Panel de Analíticas de GitHub Autoalojado",
|
||||
"category": "collaboration",
|
||||
"projectName": "gh-dashboard",
|
||||
"projectDesc": "Panel de Analíticas de GitHub Autoalojado",
|
||||
"url": "https://github.com/debba/gh-dashboard",
|
||||
"gitRepoUrl": "https://github.com/debba/gh-dashboard",
|
||||
"openSource": true,
|
||||
"projectLogo": "",
|
||||
"location": "Online",
|
||||
"startDate": "2026",
|
||||
"current": true,
|
||||
"technologies": [
|
||||
"TypeScript",
|
||||
"React 19",
|
||||
"Vite 8",
|
||||
"Node.js",
|
||||
"GitHub API",
|
||||
"Vitest",
|
||||
"Self-hosted"
|
||||
],
|
||||
"shortDescription": "Contribuidor open-source a <strong><a href='https://github.com/debba/gh-dashboard' target='_blank' rel='noopener noreferrer'>gh-dashboard</a></strong> de Andrea Debernardi — un panel de analíticas de GitHub autoalojado con puntuaciones de salud de repos, triaje de issues, resúmenes diarios y tableros Kanban. Desplegado como mi página personal en <strong><a href='https://github.txeo.club' target='_blank' rel='noopener noreferrer'>github.txeo.club</a></strong>.",
|
||||
"responsibilities": [
|
||||
"Contribuí 2 PRs fusionados (750+ líneas, 21 tests) mejorando UX, persistencia y diseño responsive",
|
||||
"Implementé caché en localStorage para estadísticas y filtros — eliminé el parpadeo de ceros al recargar la página",
|
||||
"Corregí breakpoints responsive de CSS grid y construí tabs móviles solo-icono con expansión suave al hacer hover",
|
||||
"Añadí identidad de usuario: avatar de GitHub en la barra superior, badges de organizaciones, favicon y orden de orgs primero",
|
||||
"Desplegué y mantengo una instancia en mi VPS personal con GitHub OAuth y SSL"
|
||||
],
|
||||
"projectID": "gh-dashboard"
|
||||
},
|
||||
{
|
||||
"title": "Contribuciones a Proyectos de Terceros",
|
||||
"category": "contrib",
|
||||
|
||||
@@ -76,8 +76,8 @@
|
||||
margin-top: 20px;
|
||||
/* Full justification - spread text across entire width */
|
||||
text-align: justify;
|
||||
text-align-last: justify;
|
||||
-moz-text-align-last: justify;
|
||||
text-align-last: left;
|
||||
-moz-text-align-last: left;
|
||||
text-justify: inter-word;
|
||||
/* Word breaking and hyphenation */
|
||||
word-spacing: -1px;
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
|
||||
{{define "head-language-switch"}}
|
||||
<head hx-head="merge">
|
||||
<title>{{.CV.Personal.Name}} - {{.CV.SEO.PageTitle}}</title>
|
||||
<meta name="title" content="{{.CV.Personal.Name}} - {{.CV.SEO.MetaTitle}}" hx-head="re-eval">
|
||||
<meta name="description" content="{{.CV.Personal.Title}} | {{.CV.SEO.MetaDescription}}" hx-head="re-eval">
|
||||
<link rel="canonical" href="{{.CanonicalURL}}" hx-head="re-eval">
|
||||
<link rel="alternate" hreflang="en" href="{{.AlternateEN}}" hx-head="re-eval">
|
||||
<link rel="alternate" hreflang="es" href="{{.AlternateES}}" hx-head="re-eval">
|
||||
<meta property="og:title" content="{{.CV.Personal.Name}} - {{.CV.SEO.MetaTitle}}" hx-head="re-eval">
|
||||
<meta property="og:description" content="{{.CV.Personal.Title}} | {{.CV.SEO.OgDescription}}" hx-head="re-eval">
|
||||
<meta property="og:locale" content="{{if eq .Lang "es"}}es_ES{{else}}en_US{{end}}" hx-head="re-eval">
|
||||
</head>
|
||||
{{end}}
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* HEAD-SUPPORT EXTENSION TEST
|
||||
* ============================
|
||||
* Tests that the head-support extension updates <head> tags
|
||||
* and <html lang> on language switching via HTMX
|
||||
* - Verifies <html lang> updates on language switch
|
||||
* - Verifies <title> updates on language switch
|
||||
* - Verifies <meta description> updates
|
||||
* - Verifies no duplicate tags after multiple switches
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const URL = "http://localhost:1999";
|
||||
|
||||
async function testHeadSupport() {
|
||||
console.log('🏷️ HEAD-SUPPORT EXTENSION TEST\n');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
|
||||
|
||||
const errors = [];
|
||||
const testResults = [];
|
||||
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') {
|
||||
errors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
console.log("\n1️⃣ Loading page (English default)...");
|
||||
await page.goto(URL);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// ========================================================================
|
||||
// TEST 1: Head-support extension is loaded
|
||||
// ========================================================================
|
||||
console.log("\n2️⃣ Testing head-support extension loaded...");
|
||||
const extLoaded = await page.evaluate(() => {
|
||||
const body = document.body;
|
||||
const hxExt = body.getAttribute('hx-ext');
|
||||
return {
|
||||
hasHxExt: hxExt !== null && hxExt.includes('head-support'),
|
||||
hxExtValue: hxExt
|
||||
};
|
||||
});
|
||||
|
||||
console.log(` hx-ext attribute: ${extLoaded.hxExtValue}`);
|
||||
console.log(` ${extLoaded.hasHxExt ? '✅ PASS' : '❌ FAIL'} - head-support extension activated`);
|
||||
testResults.push({ test: 'Head-support Extension Loaded', passed: extLoaded.hasHxExt });
|
||||
|
||||
// ========================================================================
|
||||
// TEST 2: Initial state - English
|
||||
// ========================================================================
|
||||
console.log("\n3️⃣ Testing initial state (English)...");
|
||||
const initialState = await page.evaluate(() => {
|
||||
return {
|
||||
htmlLang: document.documentElement.getAttribute('lang'),
|
||||
title: document.title,
|
||||
metaDesc: document.querySelector('meta[name="description"]')?.getAttribute('content') || '',
|
||||
canonical: document.querySelector('link[rel="canonical"]')?.getAttribute('href') || ''
|
||||
};
|
||||
});
|
||||
|
||||
console.log(` <html lang>: ${initialState.htmlLang}`);
|
||||
console.log(` <title>: ${initialState.title}`);
|
||||
console.log(` canonical: ${initialState.canonical}`);
|
||||
const initialOk = initialState.htmlLang === 'en';
|
||||
console.log(` ${initialOk ? '✅ PASS' : '❌ FAIL'} - Initial state is English`);
|
||||
testResults.push({ test: 'Initial English State', passed: initialOk });
|
||||
|
||||
// ========================================================================
|
||||
// TEST 3: Switch to Spanish via HTMX button
|
||||
// ========================================================================
|
||||
console.log("\n4️⃣ Switching to Spanish via HTMX...");
|
||||
|
||||
// Click the Spanish button
|
||||
const esButton = await page.$('button[aria-label="Español"]');
|
||||
if (esButton) {
|
||||
await esButton.click();
|
||||
await page.waitForTimeout(3000); // Wait for HTMX swap + head-support processing
|
||||
|
||||
const spanishState = await page.evaluate(() => {
|
||||
return {
|
||||
htmlLang: document.documentElement.getAttribute('lang'),
|
||||
title: document.title,
|
||||
metaDesc: document.querySelector('meta[name="description"]')?.getAttribute('content') || '',
|
||||
canonical: document.querySelector('link[rel="canonical"]')?.getAttribute('href') || '',
|
||||
ogLocale: document.querySelector('meta[property="og:locale"]')?.getAttribute('content') || ''
|
||||
};
|
||||
});
|
||||
|
||||
console.log(` <html lang>: ${spanishState.htmlLang}`);
|
||||
console.log(` <title>: ${spanishState.title}`);
|
||||
console.log(` canonical: ${spanishState.canonical}`);
|
||||
console.log(` og:locale: ${spanishState.ogLocale}`);
|
||||
|
||||
const langChanged = spanishState.htmlLang === 'es';
|
||||
const canonicalChanged = spanishState.canonical.includes('lang=es');
|
||||
const descChanged = spanishState.metaDesc.length > 0;
|
||||
|
||||
console.log(` ${langChanged ? '✅ PASS' : '❌ FAIL'} - <html lang> changed to "es"`);
|
||||
console.log(` ${canonicalChanged ? '✅ PASS' : '❌ FAIL'} - canonical URL updated to Spanish`);
|
||||
console.log(` ${descChanged ? '✅ PASS' : '❌ FAIL'} - meta description present`);
|
||||
|
||||
testResults.push({ test: 'HTML Lang Updated to ES', passed: langChanged });
|
||||
testResults.push({ test: 'Canonical URL Updated to ES', passed: canonicalChanged });
|
||||
testResults.push({ test: 'Meta Description Present', passed: descChanged });
|
||||
} else {
|
||||
console.log(' ❌ FAIL - Spanish button not found');
|
||||
testResults.push({ test: 'HTML Lang Updated to ES', passed: false });
|
||||
testResults.push({ test: 'Canonical URL Updated to ES', passed: false });
|
||||
testResults.push({ test: 'Meta Description Present', passed: false });
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TEST 4: Switch back to English
|
||||
// ========================================================================
|
||||
console.log("\n5️⃣ Switching back to English...");
|
||||
|
||||
const enButton = await page.$('button[aria-label="English"]');
|
||||
if (enButton) {
|
||||
await enButton.click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const englishState = await page.evaluate(() => {
|
||||
return {
|
||||
htmlLang: document.documentElement.getAttribute('lang'),
|
||||
title: document.title,
|
||||
canonical: document.querySelector('link[rel="canonical"]')?.getAttribute('href') || '',
|
||||
ogLocale: document.querySelector('meta[property="og:locale"]')?.getAttribute('content') || ''
|
||||
};
|
||||
});
|
||||
|
||||
console.log(` <html lang>: ${englishState.htmlLang}`);
|
||||
console.log(` <title>: ${englishState.title}`);
|
||||
console.log(` canonical: ${englishState.canonical}`);
|
||||
console.log(` og:locale: ${englishState.ogLocale}`);
|
||||
|
||||
const langBack = englishState.htmlLang === 'en';
|
||||
const canonicalBack = englishState.canonical.includes('lang=en');
|
||||
|
||||
console.log(` ${langBack ? '✅ PASS' : '❌ FAIL'} - <html lang> back to "en"`);
|
||||
console.log(` ${canonicalBack ? '✅ PASS' : '❌ FAIL'} - canonical URL back to English`);
|
||||
|
||||
testResults.push({ test: 'HTML Lang Restored to EN', passed: langBack });
|
||||
testResults.push({ test: 'Canonical URL Restored to EN', passed: canonicalBack });
|
||||
} else {
|
||||
console.log(' ❌ FAIL - English button not found');
|
||||
testResults.push({ test: 'HTML Lang Restored to EN', passed: false });
|
||||
testResults.push({ test: 'Canonical URL Restored to EN', passed: false });
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TEST 5: No duplicate tags after multiple switches
|
||||
// ========================================================================
|
||||
console.log("\n6️⃣ Testing no duplicate tags after multiple switches...");
|
||||
|
||||
const duplicateCheck = await page.evaluate(() => {
|
||||
const titles = document.querySelectorAll('title').length;
|
||||
const descriptions = document.querySelectorAll('meta[name="description"]').length;
|
||||
const canonicals = document.querySelectorAll('link[rel="canonical"]').length;
|
||||
const ogLocales = document.querySelectorAll('meta[property="og:locale"]').length;
|
||||
|
||||
return {
|
||||
titles,
|
||||
descriptions,
|
||||
canonicals,
|
||||
ogLocales
|
||||
};
|
||||
});
|
||||
|
||||
console.log(` <title> tags: ${duplicateCheck.titles}`);
|
||||
console.log(` <meta description> tags: ${duplicateCheck.descriptions}`);
|
||||
console.log(` <link canonical> tags: ${duplicateCheck.canonicals}`);
|
||||
console.log(` og:locale tags: ${duplicateCheck.ogLocales}`);
|
||||
|
||||
const noDuplicates = duplicateCheck.titles === 1 &&
|
||||
duplicateCheck.descriptions === 1 &&
|
||||
duplicateCheck.canonicals === 1 &&
|
||||
duplicateCheck.ogLocales === 1;
|
||||
|
||||
console.log(` ${noDuplicates ? '✅ PASS' : '❌ FAIL'} - No duplicate tags`);
|
||||
testResults.push({ test: 'No Duplicate Tags', passed: noDuplicates });
|
||||
|
||||
// ========================================================================
|
||||
// FINAL SUMMARY
|
||||
// ========================================================================
|
||||
console.log("\n" + "=".repeat(70));
|
||||
console.log("📊 TEST SUMMARY\n");
|
||||
|
||||
const totalTests = testResults.length;
|
||||
const passedTests = testResults.filter(r => r.passed).length;
|
||||
const failedTests = totalTests - passedTests;
|
||||
|
||||
testResults.forEach(result => {
|
||||
console.log(` ${result.passed ? '✅' : '❌'} ${result.test}`);
|
||||
});
|
||||
|
||||
console.log(`\n Total: ${passedTests}/${totalTests} tests passed`);
|
||||
|
||||
if (errors.length === 0) {
|
||||
console.log("\n✅ NO CONSOLE ERRORS");
|
||||
} else {
|
||||
console.log(`\n⚠️ ${errors.length} CONSOLE ERRORS`);
|
||||
}
|
||||
|
||||
console.log("=".repeat(70) + "\n");
|
||||
|
||||
await browser.close();
|
||||
|
||||
if (failedTests === 0) {
|
||||
console.log("🎉 HEAD-SUPPORT EXTENSION VALIDATED!\n");
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log("⚠️ SOME TESTS FAILED - See details above\n");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
await testHeadSupport();
|
||||
Reference in New Issue
Block a user