diff --git a/project/js/components/blog-list.js b/project/js/components/blog-list.js
new file mode 100644
index 0000000..502aa88
--- /dev/null
+++ b/project/js/components/blog-list.js
@@ -0,0 +1,202 @@
+/**
+ * Blog List Renderer
+ * ------------------
+ * - Shows a spinner while loading.
+ * - Imports each post module, then extracts only the `.preview` section (or entire content if absent).
+ * - Strips out titles, dates, badges, if inadvertently included.
+ * - Builds a list of post links with date + trimmed preview text.
+ * - Ensures UL has `list-style: none` so no bullets appear.
+ */
+
+import { TAGS } from '../routes/blog/tags.js';
+import { POSTS } from '../routes/blog/posts.js';
+import blogSubscribe from './blog-subscribe.js';
+
+export default (hostComponent) => {
+ // Initial spinner
+ hostComponent.innerHTML = `
+
+ `;
+
+ const posts = POSTS;
+
+ (async () => {
+ const modules = await Promise.all(
+ posts.map((post) => import(`../routes${post.url}.js`).catch(() => null)),
+ );
+
+ const entries = posts.map((post, index) => {
+ const mod = modules[index];
+
+ const temp = document.createElement('div');
+ temp.innerHTML = mod?.content || '';
+ const previewBlock = temp.querySelector('.preview') || temp;
+ const imgEl = temp.querySelector('img[src]:not([src="/img/nikos.jpg"])') || temp.querySelector('img');
+ previewBlock.querySelector('h1')?.remove();
+ previewBlock.querySelector('p.minor')?.remove();
+ previewBlock.querySelector('.badge')?.remove();
+ const words = previewBlock.textContent?.trim().split(/\s+/).slice(0, 50) || [];
+
+ const resolveDate = () => {
+ if (mod?.date instanceof Date) return mod.date;
+ if (post.date) {
+ const fromPost = new Date(post.date);
+ if (!Number.isNaN(fromPost)) return fromPost;
+ }
+ if (mod?.date) {
+ const parsed = new Date(mod.date);
+ if (!Number.isNaN(parsed)) return parsed;
+ }
+ return new Date('1970-01-01');
+ };
+
+ return {
+ title: post.title || mod?.title || previewBlock.querySelector('h1')?.textContent?.trim() || 'Untitled',
+ url: post.url,
+ author: post.author || mod?.author || 'Unknown',
+ image: imgEl ? imgEl.getAttribute('src') : null,
+ date: resolveDate(),
+ tags: mod?.tags || [],
+ preview: words.join(' ') + (words.length === 50 ? '…' : ''),
+ };
+ });
+
+ const latestEntry = entries.reduce((a, b) => (a.date > b.date ? a : b), entries[0]);
+ if (localStorage.getItem('notify-blog') === 'true' && latestEntry) {
+ const lastSeen = localStorage.getItem('last-post-date');
+ if (!lastSeen || new Date(lastSeen) < latestEntry.date) {
+ new Notification('New blog post', { body: latestEntry.title });
+ }
+ localStorage.setItem('last-post-date', latestEntry.date.toISOString());
+ }
+
+ let currentTag = null;
+ let currentAuthor = new URLSearchParams(location.search).get('author');
+ let sortDir = 'desc';
+
+ const controls = document.createElement('div');
+ controls.className = 'blog-controls';
+ const tagLabel = document.createElement('span');
+ tagLabel.textContent = 'All tags:';
+ const tagContainer = document.createElement('div');
+ TAGS.forEach((tag) => {
+ const btn = document.createElement('button');
+ btn.className = 'wireframe small-button tag-filter';
+ btn.textContent = tag;
+ btn.addEventListener('click', () => {
+ currentTag = currentTag === tag ? null : tag;
+ render();
+ });
+ tagContainer.appendChild(btn);
+ });
+
+ const authorLabel = document.createElement('span');
+ authorLabel.style.marginLeft = '1rem';
+ authorLabel.textContent = 'Authors:';
+ const authorContainer = document.createElement('div');
+ const authorSet = [...new Set(entries.map((e) => e.author))];
+ authorSet.forEach((name) => {
+ const btn = document.createElement('button');
+ btn.className = 'wireframe small-button author-filter';
+ btn.textContent = name;
+ btn.addEventListener('click', () => {
+ currentAuthor = currentAuthor === name ? null : name;
+ const params = new URLSearchParams(location.search);
+ if (currentAuthor) params.set('author', currentAuthor);
+ else params.delete('author');
+ history.replaceState(
+ null,
+ '',
+ location.pathname + (params.toString() ? `?${params.toString()}` : ''),
+ );
+ render();
+ });
+ authorContainer.appendChild(btn);
+ });
+
+ const sortSelect = document.createElement('select');
+ sortSelect.innerHTML = 'Newest Oldest ';
+ sortSelect.style.marginLeft = '0.5rem';
+ sortSelect.addEventListener('change', () => {
+ sortDir = sortSelect.value;
+ render();
+ });
+
+ controls.append(tagLabel, tagContainer, authorLabel, authorContainer, sortSelect);
+
+ const listWrapper = document.createElement('div');
+
+ hostComponent.innerHTML = 'Blog Posts ';
+ hostComponent.append(listWrapper, controls);
+
+ const subWrapper = document.createElement('div');
+ blogSubscribe(subWrapper);
+ hostComponent.appendChild(subWrapper);
+ hostComponent.insertAdjacentHTML(
+ 'beforeend',
+ ``,
+ );
+
+ function render() {
+ listWrapper.innerHTML = '';
+ let arr = entries.slice();
+ if (currentTag) arr = arr.filter((e) => e.tags.includes(currentTag));
+ if (currentAuthor) arr = arr.filter((e) => e.author === currentAuthor);
+ arr.sort((a, b) => (sortDir === 'desc' ? b.date - a.date : a.date - b.date));
+ if (!currentTag && !currentAuthor && sortDir === 'desc') arr = arr.slice(0, 8);
+
+ const ul = document.createElement('ul');
+ ul.className = 'blog-list';
+ arr.forEach((entry) => {
+ const li = document.createElement('li');
+ const img = document.createElement('img');
+ img.src = entry.image || '/img/nikos.jpg';
+ img.alt = '';
+ img.className = 'preview-img';
+ li.appendChild(img);
+ const wrap = document.createElement('div');
+ const link = document.createElement('a');
+ link.href = entry.url;
+ link.textContent = entry.title;
+
+ const metaEl = document.createElement('p');
+ metaEl.className = 'minor';
+ metaEl.textContent = `${entry.author} - ${entry.date.toDateString()}`;
+
+ const preview = document.createElement('p');
+ preview.className = 'preview-text';
+ preview.textContent = entry.preview;
+
+ const tagEl = document.createElement('div');
+ entry.tags.forEach((t) => {
+ const tagBtn = document.createElement('button');
+ tagBtn.className = 'wireframe small-button tag-filter';
+ tagBtn.textContent = t;
+ tagBtn.addEventListener('click', () => {
+ currentTag = currentTag === t ? null : t;
+ render();
+ });
+ tagEl.appendChild(tagBtn);
+ });
+ wrap.append(link, metaEl, preview, tagEl);
+ li.appendChild(wrap);
+ ul.appendChild(li);
+ });
+ listWrapper.appendChild(ul);
+ }
+
+ render();
+ })();
+};
diff --git a/project/js/routes/blog/posts.js b/project/js/routes/blog/posts.js
new file mode 100644
index 0000000..c7ad515
--- /dev/null
+++ b/project/js/routes/blog/posts.js
@@ -0,0 +1,33 @@
+// Auto-generated by scripts/build-blog.mjs. Do not edit manually.
+export const POSTS = [
+ {
+ title: "Understanding Lorem Ipsum",
+ url: "/blog/1-lorem-ipsum",
+ author: "Nikos Katsikanis",
+ date: "2025-01-01T00:00:00.000Z",
+ },
+ {
+ title: "Dolor Sit Amet Explained",
+ url: "/blog/2-dolor-sit",
+ author: "Nikos Katsikanis",
+ date: "2025-01-02T00:00:00.000Z",
+ },
+ {
+ title: "Consectetur Adipiscing Tips",
+ url: "/blog/3-consectetur",
+ author: "Nikos Katsikanis",
+ date: "2025-01-03T00:00:00.000Z",
+ },
+ {
+ title: "Sed Do Eiusmod Insights",
+ url: "/blog/4-adipiscing",
+ author: "Nikos Katsikanis",
+ date: "2025-01-04T00:00:00.000Z",
+ },
+ {
+ title: "Vanilla JS Patterns Release News",
+ url: "/blog/5-breaking-news",
+ author: "Nikos Katsikanis",
+ date: "2025-05-01T00:00:00.000Z",
+ }
+];
diff --git a/project/scripts/build-blog.mjs b/project/scripts/build-blog.mjs
new file mode 100644
index 0000000..ce4ffeb
--- /dev/null
+++ b/project/scripts/build-blog.mjs
@@ -0,0 +1,162 @@
+import fs from 'fs/promises';
+import path from 'path';
+import { fileURLToPath, pathToFileURL } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const root = path.resolve(__dirname, '..');
+const blogRoutesDir = path.join(root, 'js', 'routes', 'blog');
+
+const extractPreview = (html) => {
+ const match = /([\s\S]*?)<\/div>/i.exec(html);
+ const text = match ? match[1].replace(/<[^>]+>/g, '').trim() : '';
+ const words = text.split(/\s+/).slice(0, 30);
+ return words.join(' ');
+};
+
+const extractImage = (html) => {
+ const match = /
]+src=["']([^"']+)["']/i.exec(html);
+ return match ? match[1] : '/img/nikos.jpg';
+};
+
+const extractTitle = (html, fallback) => {
+ const match = /
]*>([^<]+)<\/h1>/i.exec(html);
+ if (match) return match[1].trim();
+ return fallback;
+};
+
+const humanizeSlug = (slug) =>
+ slug
+ .replace(/^\d+-/, '')
+ .split('-')
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(' ');
+
+const loadBlogModules = async () => {
+ const files = (await fs.readdir(blogRoutesDir)).filter(
+ (file) => file.endsWith('.js') && file !== 'posts.js' && file !== 'tags.js',
+ );
+
+ const modules = await Promise.all(
+ files.map((file) => import(pathToFileURL(path.join(blogRoutesDir, file))).catch(() => null)),
+ );
+
+ const entries = files.map((file, index) => {
+ const module = modules[index] || {};
+ const slug = file.replace(/\.js$/, '');
+ const url = `/blog/${slug}`;
+ const rawDate = module.date instanceof Date ? module.date : module.date ? new Date(module.date) : null;
+ const date = rawDate && !Number.isNaN(rawDate) ? rawDate : null;
+ const content = module.content || '';
+
+ return {
+ slug,
+ url,
+ title: extractTitle(content, humanizeSlug(slug)),
+ author: module.author || 'Unknown',
+ date,
+ isoDate: date ? date.toISOString() : null,
+ content,
+ };
+ });
+
+ entries.sort((a, b) => {
+ if (a.date && b.date) return a.date - b.date;
+ if (a.date) return -1;
+ if (b.date) return 1;
+ return a.slug.localeCompare(b.slug);
+ });
+
+ return entries;
+};
+
+const writePostsModule = async (posts) => {
+ const lines = posts
+ .map((post) => {
+ const items = [
+ ` title: ${JSON.stringify(post.title)},`,
+ ` url: ${JSON.stringify(post.url)},`,
+ ` author: ${JSON.stringify(post.author)},`,
+ ];
+ if (post.isoDate) {
+ items.push(` date: ${JSON.stringify(post.isoDate)},`);
+ }
+ return ` {
+${items.join('\n')}
+ }`;
+ })
+ .join(',\n');
+
+ const header = `// Auto-generated by scripts/build-blog.mjs. Do not edit manually.\n`;
+ const content = `${header}export const POSTS = [\n${lines}\n];\n`;
+ await fs.writeFile(path.join(blogRoutesDir, 'posts.js'), content);
+};
+
+const template = ({ title, description, image, url, content }) => `
+
+
+
+ ${title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Copyright Nikos Katsikanis LTD
+
+
+
+`;
+
+const posts = await loadBlogModules();
+await writePostsModule(posts);
+
+for (const post of posts) {
+ const rel = post.url.replace(/^\//, '');
+ const content = post.content || '';
+ const description = extractPreview(content);
+ const image = extractImage(content);
+ const html = template({ title: post.title, description, image, url: post.url, content });
+ const outDir = path.join(root, rel);
+ await fs.mkdir(outDir, { recursive: true });
+ await fs.writeFile(path.join(outDir, 'index.html'), html);
+}
+
+const blogIndexHtml = template({
+ title: 'Blog',
+ description: 'Latest posts',
+ image: '',
+ url: '/blog',
+ content: '
',
+});
+await fs.writeFile(path.join(root, 'blog', 'index.html'), blogIndexHtml);