Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions public/blog/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
@import "/index.css";

.cards {
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
grid-gap: 2em;
row-gap: 0;
grid-auto-flow: row;
grid-auto-rows: minmax(100px, auto);
}

.card {
display: block;
position: relative;
list-style: none;
padding: 1em;
margin: 0 -1em;
font-size: 0.9em;
border-radius: 5px;
container-type: inline-size;
container-name: card;
}

.card img {
width: 100%;
height: 120px;
object-fit: cover;
margin-bottom: 0.5em;
}

.card h3 {
margin-top: 0;
margin-bottom: 0.5em;
}

.card a {
text-decoration: none;
color: inherit;
}

/* make the whole card focusable */
.card:focus-within {
box-shadow: 0 0 0 0.25rem;
}

.card:focus-within a:focus {
text-decoration: none;
}

/* turn the whole card into the clickable area */
.card h3 a::after {
display: block;
position: absolute;
content: "";
top: 0;
left: 0;
right: 0;
bottom: 0;
}

/* make byline links clickable */
.card small {
position: relative;
z-index: 10;
color: var(--text-color-mute);
}

.card small a {
text-decoration: underline;
}

.card small a:hover {
color: var(--text-color);
}

/* for hero cards (full width), move the image to the left */
@container card ( min-width: 500px ) {
.card img {
float: left;
width: calc(50% - 1em);
height: 200px;
margin-right: 2em;
margin-bottom: 0;
}
}

.archive {
text-align: center;
color: var(--text-color-mute);

a:not(:hover) {
color: inherit;
}
}

.byline {
color: var(--text-color-mute);
font-size: 0.8em;
margin-bottom: 1.5em;
}

blog-header {
display: block;
margin-bottom: 1.5em;
text-align: center;
}

blog-header nav {
margin-bottom: 2em;
}

main img {
margin: 0.5em 0;
}

@media (scripting: none) {
blog-header::before {
content: 'Please enable scripting to view the navigation'
}
}

.comments {
margin-top: 2em;
text-align: center;
}
15 changes: 7 additions & 8 deletions public/pages/blog.html → public/blog/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
<meta name="description" content="Thoughts and Experiments">
<meta name="author" content="Luis Victoria">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="../favicon.ico">
<link rel="stylesheet" href="../index.css">
<link rel="alternate" type="application/atom+xml" title="Luis Victoria's Blog Feed" href="../feed.atom">
<script type="module" src="../index.js" defer></script>
<link rel="icon" href="/favicon.ico">
<link rel="stylesheet" href="./index.css">
<link rel="alternate" type="application/atom+xml" title="Luis Victoria's Blog Feed" href="/feed.atom">
<script type="module" src="./index.js" defer></script>
</head>

<body>
Expand All @@ -21,7 +21,7 @@ <h1>Luis Victoria</h1>

<nav>
<ul>
<li><a href="../index.html">Home</a></li>
<li><a href="/">Home</a></li>
<li><a href="#" aria-current="page">Blog</a></li>
</ul>
</nav>
Expand All @@ -33,8 +33,7 @@ <h1>Luis Victoria</h1>
</header>

<main>
<p>I just want to say...</p>
<lv-hello-world></lv-hello-world>
<lv-blog-posts></lv-blog-posts>
</main>

<footer>
Expand All @@ -43,7 +42,7 @@ <h1>Luis Victoria</h1>
<a href="https://github.com/lv">GitHub</a>
<a href="https://www.linkedin.com/in/luisvictoria">LinkedIn</a>
<a href="https://lichess.org/@/lavp">Lichess</a>
<a href="../feed.atom">RSS</a>
<a href="/feed.atom">RSS</a>
</div>
<br>
<p>&copy; 2025 Luis Victoria</p>
Expand Down
8 changes: 8 additions & 0 deletions public/blog/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { blogPostsComponent } from "../../components/blog-posts.js";

const app = () => {
blogPostsComponent();
}

// Ensure that HTML is fully loaded before registering components
document.addEventListener('DOMContentLoaded', app);
68 changes: 68 additions & 0 deletions public/components/blog-posts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Refined from https://plainvanillaweb.com/blog/components/blog-latest-posts.js

import { html } from '../lib/html.js';

/** @type {number} */
const ENTRIES_TO_SHOW = 6;

class LatestPosts extends HTMLElement {
// `connectedCallback` immediately loads; doesn't need explicit call to a function
connectedCallback() {
// Fallback text to display while content loads
this.textContent = "Loading...";

fetch('/feed.atom')
.then(response => response.text())
.then(xmlText => {
// Convert the XML file string into queryable DOM
/** @type {Document} */
const xmlDoc = new DOMParser().parseFromString(xmlText, "text/xml");

/** @type {Element | null} */
const parserError = xmlDoc.querySelector('parsererror');
if (parserError) {
throw new Error('Invalid XML');
}

// Iterate across all `<entry>` tags in the feed
/** @type {NodeListOf<Element>} */
const entries = xmlDoc.querySelectorAll('entry');

const feedItems = Array.from(entries)
.slice(0, ENTRIES_TO_SHOW)
.map(entry => ({
title: entry.querySelector('title')?.textContent,
link: entry.querySelector('link')?.getAttribute('href'),
published: entry.querySelector('published')?.textContent,
summary: entry.querySelector('summary')?.textContent
}))
.filter(item => item.title && item.link);

if (feedItems.length === 0) {
this.innerHTML = 'No blog posts found.';
return;
}

this.innerHTML = String(html`
<ul class="cards">
${feedItems.map(item => html`
<li class="card">
<h3><a href="${item.link}">${item.title}</a></h3>
<p>${item.summary}</p>
<small>
<time datetime="${item.published}">
${item.published ? new Date(item.published).toLocaleDateString('en-US', { dateStyle: 'long' }) : 'No date'}
</time>
</small>
</li>
`).join('')}
</ul>
`);
})
.catch(error => {
this.textContent = `Error: ${error.message}`;
});
}
}

export const blogPostsComponent = () => customElements.define('lv-blog-posts', LatestPosts);
12 changes: 0 additions & 12 deletions public/components/hello-world.js

This file was deleted.

12 changes: 6 additions & 6 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
<meta name="description" content="Creating to Understand">
<meta name="author" content="Luis Victoria">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="./favicon.ico">
<link rel="stylesheet" href="./index.css">
<link rel="alternate" type="application/atom+xml" title="Luis Victoria's Blog Feed" href="./feed.atom">
<script type="module" src="./index.js" defer></script>
<link rel="icon" href="/favicon.ico">
<link rel="stylesheet" href="/index.css">
<link rel="alternate" type="application/atom+xml" title="Luis Victoria's Blog Feed" href="/feed.atom">
<script type="module" src="/index.js" defer></script>
</head>

<body>
Expand All @@ -22,7 +22,7 @@ <h1>Luis Victoria</h1>
<nav>
<ul>
<li><a href="#" aria-current="page">Home</a></li>
<li><a href="./pages/blog.html">Blog</a></li>
<li><a href="blog/">Blog</a></li>
</ul>
</nav>

Expand Down Expand Up @@ -50,7 +50,7 @@ <h1>Luis Victoria</h1>
<a href="https://github.com/lv">GitHub</a>
<a href="https://www.linkedin.com/in/luisvictoria">LinkedIn</a>
<a href="https://lichess.org/@/lavp">Lichess</a>
<a href="./feed.atom">RSS</a>
<a href="/feed.atom">RSS</a>
</div>
<br>
<p>&copy; 2025 Luis Victoria</p>
Expand Down
3 changes: 0 additions & 3 deletions public/index.js

This file was deleted.

98 changes: 98 additions & 0 deletions public/lib/html.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Refined from https://plainvanillaweb.com/lib/html.js

class Html extends String { }


/**
* Marks a string as safe HTML that should not be encoded
*
* Should be used when you have a string that contains intentional HTML
* markup that should be preserved as-is, rather than being escaped.
*
* Examples:
* htmlRaw('<b>Bold text</b>') -> Html('<b>Bold text</b>')
* html`<p>${htmlRaw('<em>Emphasis</em>')}</p>` -> '<p><em>Emphasis</em></p>'
*
*
* @param {string} str - HTML string that should not be encoded
* @returns {Html} - String wrapped in `Html` class to skip encoding
*/
export const htmlRaw = str => new Html(str);


/** @type {Record<string, string>} */
const HTML_ENTITIES = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&apos;', // sexier than using `&#39;`
'"': '&quot;'
};

/**
* Escapes special HTML characters for XSS protection
*
* Converts special HTML characters into their HTML entity equivalents,
* ensuring they display as literal text instead of being interpreted
* as HTML markup
*
* Examples:
* htmlEncode('<script>') -> '&lt;script&gt;'
* htmlEncode('Tom & Jerry') -> 'Tom &amp; Jerry'
* htmlEncode(htmlRaw('<b>Bold</b>')) -> '<b>Bold</b>' (no change)
*
*
* @param {*} value - Any value to be safely inserted into HTML
* @returns {Html} - HTML-safe string wrapped in `Html` class
*/
export const htmlEncode = (value) => {
// Avoid double-encoding if value is already safe `Html`
if (value instanceof Html) return value;

return htmlRaw(
String(value).replace(/[&<>'"]/g, (char) => HTML_ENTITIES[char])
);
};


/**
* Template literal tag that safely builds HTML by auto-encoding user data
*
* Example usage:
* html`<p>Hello, ${userName}</p>`
*
* When you write the above, the function receives:
* strings = ['<p>Hello, ', '</p>'] // The literal HTML parts
* values = [userName] // The ${} interpolated values
*
* It then:
* 1. HTML-encodes each user value (so "<script>" becomes "&lt;script&gt;")
* 2. Weaves the encoded values back to the HTML template
* 3. Returns the result as safe HTML
*
*
* Example:
* const userName = '<script>alert("xss")</script>';
* html`<p>Hello, ${userName}</p>`
* // Result: '<p>Hello, &lt;script&gt;alert("xss")&lt;/script&gt;</p>'
*
*
* @param {TemplateStringsArray} strings - The literal parts of the template
* @param {...*} values - The interpolated ${} values that need encoding
* @returns {Html} - Safe HTML string marked as already processed
*/
export const html = (strings, ...values) =>
htmlRaw(
String.raw(
{ raw: strings },
// Transform each user value into a safe string before inserting
...values.map(v => String(htmlEncode(v)))
)
);

// `String.raw` does the following:
// String.raw({ raw: ['<p>Hello, ', '</p>'] }, 'SAFE_USER_DATA')
// Result: '<p>Hello, SAFE_USER_DATA</p>'
//
// Equivalently: strings[0] + values[0] + strings[1] + values[1] + ...
// but preserves literal backslashes in the HTML templates
Loading
Loading