Skip to content
Open
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
202 changes: 202 additions & 0 deletions project/js/components/blog-list.js
Original file line number Diff line number Diff line change
@@ -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 = `
<div class="blog-spinner"></div>
`;

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 = '<option value="desc">Newest</option><option value="asc">Oldest</option>';
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 = '<h2 class="blog-title">Blog Posts</h2>';
hostComponent.append(listWrapper, controls);

const subWrapper = document.createElement('div');
blogSubscribe(subWrapper);
hostComponent.appendChild(subWrapper);
hostComponent.insertAdjacentHTML(
'beforeend',
`<style>
ul.blog-list { list-style: none; padding: 0; margin: 0; }
ul.blog-list li { margin-bottom: 1.5rem; display: flex; gap: 0.5rem; align-items: center; }
ul.blog-list img.preview-img { width: 80px; height: 80px; object-fit: cover; flex-shrink: 0; padding: 1rem; border-radius: 1rem; }
ul.blog-list a { font-weight: bold; color: var(--primary-color); text-decoration: none; }
ul.blog-list p.minor { font-size: 0.9rem; color: #888; margin: 0.2rem 0; }
ul.blog-list p.preview-text { margin: 0.5rem 0 0; }
.blog-spinner { border: 4px solid #f3f3f3; border-top: 4px solid var(--primary-color); border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 2rem auto; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.tag-filter { margin-right: 0.3rem; margin-bottom: 0.3rem; }
.author-filter { margin-right: 0.3rem; margin-bottom: 0.3rem; }
.blog-controls { margin-top: 1rem; display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem; }
</style>`,
);

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();
})();
};
33 changes: 33 additions & 0 deletions project/js/routes/blog/posts.js
Original file line number Diff line number Diff line change
@@ -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",
}
];
162 changes: 162 additions & 0 deletions project/scripts/build-blog.mjs
Original file line number Diff line number Diff line change
@@ -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 = /<div class="preview">([\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 = /<img[^>]+src=["']([^"']+)["']/i.exec(html);
return match ? match[1] : '/img/nikos.jpg';
};

const extractTitle = (html, fallback) => {
const match = /<h1[^>]*>([^<]+)<\/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 }) => `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${title}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${description}">
<meta property="og:image" content="${image}">
<meta property="og:url" content="${url}">
<meta property="twitter:title" content="${title}">
<meta property="twitter:description" content="${description}">
<meta property="twitter:image" content="${image}">
<meta property="twitter:url" content="${url}">
<meta name="twitter:card" content="${image ? 'summary_large_image' : 'summary'}">
<link rel="icon" href="data:,">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/modern-normalize/2.0.0/modern-normalize.min.css">
<link rel="stylesheet" href="/css/reset.css">
<link rel="stylesheet" href="/css/utility.css">
<link rel="stylesheet" href="/css/common.css">
<link rel="stylesheet" href="/css/form.css">
<link rel="stylesheet" href="/css/button-badge.css">
<link rel="stylesheet" href="/css/typography.css">
<link rel="stylesheet" href="/css/light.css" media="(prefers-color-scheme: light)">
<link rel="stylesheet" href="/css/dark.css" media="(prefers-color-scheme: dark)">
<script src="/js/store.js" defer type="module"></script>
<script src="/js/componentLoader.js" defer type="module"></script>
</head>
<body class="flex flex-col">
<div class="site-container flex flex-col" style="flex: 1">
<nav data-component="nav" data-header-bar="true" data-burger-px="400"></nav>
<main data-component="router" style="padding: 1rem; width: 100%; flex: 1">
${content}
</main>
<div class="flex justify-center">
<a href="https://github.com/quantuminformation/vanillajs-patterns" target="_blank">
Fork me on GitHub
</a>
</div>
</div>
<footer>
<span>Copyright Nikos Katsikanis LTD</span>
<div data-component="theme-switcher"></div>
</footer>
</body>
</html>`;

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: '<div data-component="blog-list"></div>',
});
await fs.writeFile(path.join(root, 'blog', 'index.html'), blogIndexHtml);