From c7699d258c671edf33a67c8c873a477cb476532b Mon Sep 17 00:00:00 2001 From: Nikos Date: Wed, 24 Sep 2025 21:36:21 +0100 Subject: [PATCH] Automate blog metadata and harden list rendering --- project/js/components/blog-list.js | 202 +++++++++++++++++++++++++++++ project/js/routes/blog/posts.js | 33 +++++ project/scripts/build-blog.mjs | 162 +++++++++++++++++++++++ 3 files changed, 397 insertions(+) create mode 100644 project/js/components/blog-list.js create mode 100644 project/js/routes/blog/posts.js create mode 100644 project/scripts/build-blog.mjs 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 = ''; + 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} + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ ${content} +
+ +
+
+ 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);