diff --git a/blog/1-lorem-ipsum/index.html b/blog/1-lorem-ipsum/index.html
new file mode 100644
index 0000000..150ee1f
--- /dev/null
+++ b/blog/1-lorem-ipsum/index.html
@@ -0,0 +1,54 @@
+
+
+
+
🧮
Web GPU tutorial
@@ -202,8 +210,8 @@ export default (hostComponent) => {
if (headerBar === 'true' && burgerPx) {
hostComponent.parentElement.insertAdjacentHTML(
- 'afterbegin',
- `
+ 'afterbegin',
+ `
- `
+ `,
);
burgerButton = hostComponent.parentElement.querySelector('.burger-button');
diff --git a/js/components/post-nav.js b/js/components/post-nav.js
new file mode 100644
index 0000000..d62fee0
--- /dev/null
+++ b/js/components/post-nav.js
@@ -0,0 +1,28 @@
+import { POSTS } from '../routes/blog/posts.js';
+
+export default function postNav(currentUrl) {
+ const nav = document.createElement('div');
+ nav.className = 'post-nav';
+
+ const prev = document.createElement('a');
+ prev.className = 'prev';
+ const spacer = document.createElement('span');
+ const next = document.createElement('a');
+ next.className = 'next';
+
+ const idx = POSTS.findIndex((p) => p.url === currentUrl);
+ if (idx > 0) {
+ const prevPost = POSTS[idx - 1];
+ prev.href = prevPost.url;
+ prev.textContent = `⇐ ${prevPost.title}`; // big left arrow
+ }
+
+ if (idx !== -1 && idx < POSTS.length - 1) {
+ const nextPost = POSTS[idx + 1];
+ next.href = nextPost.url;
+ next.textContent = `${nextPost.title} ⇒`; // big right arrow
+ }
+
+ nav.append(prev, spacer, next);
+ return nav;
+}
diff --git a/js/components/share-buttons.js b/js/components/share-buttons.js
new file mode 100644
index 0000000..63e6e2a
--- /dev/null
+++ b/js/components/share-buttons.js
@@ -0,0 +1,44 @@
+import { twitterIcon, linkedinIcon, facebookIcon, redditIcon } from '../assets/socialIconData.js';
+import canonicalUrl from '../utils/canonicalUrl.js';
+
+export default function shareButtons(title) {
+ const url = encodeURIComponent(canonicalUrl());
+ const text = encodeURIComponent(title);
+ const container = document.createElement('div');
+ container.className = 'share-buttons';
+
+ const links = [
+ {
+ href: `https://twitter.com/intent/tweet?url=${url}&text=${text}`,
+ icon: twitterIcon,
+ label: 'Twitter',
+ },
+ {
+ href: `https://www.linkedin.com/shareArticle?mini=true&url=${url}&title=${text}&summary=${text}&source=nikoskatsikanis.com`,
+ icon: linkedinIcon,
+ label: 'LinkedIn',
+ },
+ {
+ href: `https://www.facebook.com/sharer/sharer.php?u=${url}`,
+ icon: facebookIcon,
+ label: 'Facebook',
+ },
+ {
+ href: `https://www.reddit.com/submit?url=${url}&title=${text}`,
+ icon: redditIcon,
+ label: 'Reddit',
+ },
+ ];
+
+ links.forEach(({ href, icon, label }) => {
+ const a = document.createElement('a');
+ a.href = href;
+ a.target = '_blank';
+ a.rel = 'noopener';
+ a.title = `Share on ${label}`;
+ a.innerHTML = icon;
+ container.appendChild(a);
+ });
+
+ return container;
+}
diff --git a/js/components/update-meta.js b/js/components/update-meta.js
new file mode 100644
index 0000000..e98b4a9
--- /dev/null
+++ b/js/components/update-meta.js
@@ -0,0 +1,41 @@
+export default function updateMeta({ title, description, image, url, author }) {
+ const abs = (value) => {
+ try {
+ return value ? new URL(value, location.origin).href : '';
+ } catch {
+ return value;
+ }
+ };
+
+ const set = (property, content) => {
+ if (!content) return;
+ let tag = document.querySelector(`meta[property="${property}"]`);
+ if (!tag) {
+ tag = document.createElement('meta');
+ tag.setAttribute('property', property);
+ document.head.appendChild(tag);
+ }
+ tag.setAttribute('content', content);
+ };
+
+ const setName = (name, content) => {
+ if (!content) return;
+ let tag = document.querySelector(`meta[name="${name}"]`);
+ if (!tag) {
+ tag = document.createElement('meta');
+ tag.setAttribute('name', name);
+ document.head.appendChild(tag);
+ }
+ tag.setAttribute('content', content);
+ };
+ set('og:title', title);
+ set('og:description', description);
+ set('og:image', abs(image));
+ set('og:url', abs(url));
+ set('twitter:title', title);
+ set('twitter:description', description);
+ set('twitter:image', abs(image));
+ set('twitter:url', abs(url));
+ set('twitter:card', image ? 'summary_large_image' : 'summary');
+ setName('author', author);
+}
diff --git a/js/routes/blog.js b/js/routes/blog.js
new file mode 100644
index 0000000..d8ea753
--- /dev/null
+++ b/js/routes/blog.js
@@ -0,0 +1,3 @@
+export default (hostComponent) => {
+ hostComponent.innerHTML = '';
+};
diff --git a/js/routes/blog/1-lorem-ipsum.js b/js/routes/blog/1-lorem-ipsum.js
new file mode 100644
index 0000000..9e4f3cc
--- /dev/null
+++ b/js/routes/blog/1-lorem-ipsum.js
@@ -0,0 +1,43 @@
+import { GENERAL } from './tags.js';
+import postNav from '../../components/post-nav.js';
+import blogSubscribe from '../../components/blog-subscribe.js';
+import shareButtons from '../../components/share-buttons.js';
+import updateMeta from '../../components/update-meta.js';
+import canonicalUrl from '../../utils/canonicalUrl.js';
+
+export const date = new Date('2025-01-01');
+export const dateText = date.toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+});
+export const author = 'Nikos Katsikanis';
+export const tags = [GENERAL];
+
+export const content = `
+ Understanding Lorem Ipsum
+ ${author} - ${dateText}
+
+
Lorem ipsum is placeholder text commonly used to demonstrate the visual form of a document without relying on meaningful content.
+
+ This article explores the history of lorem ipsum and why designers rely on it.
+
+`;
+
+export default (hostComponent) => {
+ hostComponent.innerHTML = content;
+ const discuss = hostComponent.querySelector('div[data-component="discuss"]');
+ hostComponent.insertBefore(shareButtons('Understanding Lorem Ipsum'), discuss);
+ hostComponent.insertBefore(postNav('/blog/1-lorem-ipsum'), discuss);
+ blogSubscribe(hostComponent);
+ const firstImg = hostComponent.querySelector('img')?.src || new URL('/img/nikos.jpg', location.origin).href;
+ const previewText =
+ hostComponent.querySelector('.preview')?.textContent.trim().split(/\\s+/).slice(0, 30).join(' ') || '';
+ updateMeta({
+ title: 'Understanding Lorem Ipsum',
+ description: previewText,
+ image: firstImg,
+ url: canonicalUrl(),
+ author,
+ });
+};
diff --git a/js/routes/blog/2-dolor-sit.js b/js/routes/blog/2-dolor-sit.js
new file mode 100644
index 0000000..c4cb35e
--- /dev/null
+++ b/js/routes/blog/2-dolor-sit.js
@@ -0,0 +1,43 @@
+import { GENERAL } from './tags.js';
+import postNav from '../../components/post-nav.js';
+import blogSubscribe from '../../components/blog-subscribe.js';
+import shareButtons from '../../components/share-buttons.js';
+import updateMeta from '../../components/update-meta.js';
+import canonicalUrl from '../../utils/canonicalUrl.js';
+
+export const date = new Date('2025-01-02');
+export const dateText = date.toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+});
+export const author = 'Nikos Katsikanis';
+export const tags = [GENERAL];
+
+export const content = `
+ Dolor Sit Amet Explained
+ ${author} - ${dateText}
+
+
Dolor sit amet is a familiar phrase that keeps attention on layout rather than meaning.
+
+ Using neutral words lets readers focus on typography, spacing, and overall design.
+
+`;
+
+export default (hostComponent) => {
+ hostComponent.innerHTML = content;
+ const discuss = hostComponent.querySelector('div[data-component="discuss"]');
+ hostComponent.insertBefore(shareButtons('Dolor Sit Amet Explained'), discuss);
+ hostComponent.insertBefore(postNav('/blog/2-dolor-sit'), discuss);
+ blogSubscribe(hostComponent);
+ const firstImg = hostComponent.querySelector('img')?.src || new URL('/img/nikos.jpg', location.origin).href;
+ const previewText =
+ hostComponent.querySelector('.preview')?.textContent.trim().split(/\\s+/).slice(0, 30).join(' ') || '';
+ updateMeta({
+ title: 'Dolor Sit Amet Explained',
+ description: previewText,
+ image: firstImg,
+ url: canonicalUrl(),
+ author,
+ });
+};
diff --git a/js/routes/blog/3-consectetur.js b/js/routes/blog/3-consectetur.js
new file mode 100644
index 0000000..1721869
--- /dev/null
+++ b/js/routes/blog/3-consectetur.js
@@ -0,0 +1,43 @@
+import { GENERAL } from './tags.js';
+import postNav from '../../components/post-nav.js';
+import blogSubscribe from '../../components/blog-subscribe.js';
+import shareButtons from '../../components/share-buttons.js';
+import updateMeta from '../../components/update-meta.js';
+import canonicalUrl from '../../utils/canonicalUrl.js';
+
+export const date = new Date('2025-01-03');
+export const dateText = date.toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+});
+export const author = 'Nikos Katsikanis';
+export const tags = [GENERAL];
+
+export const content = `
+ Consectetur Adipiscing Tips
+ ${author} - ${dateText}
+
+
Consectetur adipiscing demonstrates how paragraphs wrap and align within a column.
+
+ Adjust the width of your browser to see how the text responds to different spaces.
+
+`;
+
+export default (hostComponent) => {
+ hostComponent.innerHTML = content;
+ const discuss = hostComponent.querySelector('div[data-component="discuss"]');
+ hostComponent.insertBefore(shareButtons('Consectetur Adipiscing Tips'), discuss);
+ hostComponent.insertBefore(postNav('/blog/3-consectetur'), discuss);
+ blogSubscribe(hostComponent);
+ const firstImg = hostComponent.querySelector('img')?.src || new URL('/img/nikos.jpg', location.origin).href;
+ const previewText =
+ hostComponent.querySelector('.preview')?.textContent.trim().split(/\\s+/).slice(0, 30).join(' ') || '';
+ updateMeta({
+ title: 'Consectetur Adipiscing Tips',
+ description: previewText,
+ image: firstImg,
+ url: canonicalUrl(),
+ author,
+ });
+};
diff --git a/js/routes/blog/4-adipiscing.js b/js/routes/blog/4-adipiscing.js
new file mode 100644
index 0000000..c6e4c18
--- /dev/null
+++ b/js/routes/blog/4-adipiscing.js
@@ -0,0 +1,43 @@
+import { GENERAL } from './tags.js';
+import postNav from '../../components/post-nav.js';
+import blogSubscribe from '../../components/blog-subscribe.js';
+import shareButtons from '../../components/share-buttons.js';
+import updateMeta from '../../components/update-meta.js';
+import canonicalUrl from '../../utils/canonicalUrl.js';
+
+export const date = new Date('2025-01-04');
+export const dateText = date.toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+});
+export const author = 'Nikos Katsikanis';
+export const tags = [GENERAL];
+
+export const content = `
+ Sed Do Eiusmod Insights
+ ${author} - ${dateText}
+
+
Sed do eiusmod tempor incididunt shows how longer phrases feel in a block of text.
+
+ These sentences are intentionally plain so that emphasis stays on styling rather than narrative.
+
+`;
+
+export default (hostComponent) => {
+ hostComponent.innerHTML = content;
+ const discuss = hostComponent.querySelector('div[data-component="discuss"]');
+ hostComponent.insertBefore(shareButtons('Sed Do Eiusmod Insights'), discuss);
+ hostComponent.insertBefore(postNav('/blog/4-adipiscing'), discuss);
+ blogSubscribe(hostComponent);
+ const firstImg = hostComponent.querySelector('img')?.src || new URL('/img/nikos.jpg', location.origin).href;
+ const previewText =
+ hostComponent.querySelector('.preview')?.textContent.trim().split(/\\s+/).slice(0, 30).join(' ') || '';
+ updateMeta({
+ title: 'Sed Do Eiusmod Insights',
+ description: previewText,
+ image: firstImg,
+ url: canonicalUrl(),
+ author,
+ });
+};
diff --git a/js/routes/blog/5-breaking-news.js b/js/routes/blog/5-breaking-news.js
new file mode 100644
index 0000000..3ecdaeb
--- /dev/null
+++ b/js/routes/blog/5-breaking-news.js
@@ -0,0 +1,43 @@
+import { NEWS } from './tags.js';
+import postNav from '../../components/post-nav.js';
+import blogSubscribe from '../../components/blog-subscribe.js';
+import shareButtons from '../../components/share-buttons.js';
+import updateMeta from '../../components/update-meta.js';
+import canonicalUrl from '../../utils/canonicalUrl.js';
+
+export const date = new Date('2025-05-01');
+export const dateText = date.toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+});
+export const author = 'Nikos Katsikanis';
+export const tags = [NEWS];
+
+export const content = `
+ Vanilla JS Patterns Release News
+ ${author} - ${dateText}
+
+
This brief news update highlights recent changes in the Vanilla JS Patterns project.
+
+ More details will follow as the project evolves and new features are introduced.
+
+`;
+
+export default (hostComponent) => {
+ hostComponent.innerHTML = content;
+ const discuss = hostComponent.querySelector('div[data-component="discuss"]');
+ hostComponent.insertBefore(shareButtons('Vanilla JS Patterns Release News'), discuss);
+ hostComponent.insertBefore(postNav('/blog/5-breaking-news'), discuss);
+ blogSubscribe(hostComponent);
+ const firstImg = hostComponent.querySelector('img')?.src || new URL('/img/nikos.jpg', location.origin).href;
+ const previewText =
+ hostComponent.querySelector('.preview')?.textContent.trim().split(/\s+/).slice(0, 30).join(' ') || '';
+ updateMeta({
+ title: 'Vanilla JS Patterns Release News',
+ description: previewText,
+ image: firstImg,
+ url: canonicalUrl(),
+ author,
+ });
+};
diff --git a/js/routes/blog/posts.js b/js/routes/blog/posts.js
new file mode 100644
index 0000000..b61d786
--- /dev/null
+++ b/js/routes/blog/posts.js
@@ -0,0 +1,27 @@
+export const POSTS = [
+ {
+ title: 'Understanding Lorem Ipsum',
+ url: '/blog/1-lorem-ipsum',
+ author: 'Nikos Katsikanis',
+ },
+ {
+ title: 'Dolor Sit Amet Explained',
+ url: '/blog/2-dolor-sit',
+ author: 'Nikos Katsikanis',
+ },
+ {
+ title: 'Consectetur Adipiscing Tips',
+ url: '/blog/3-consectetur',
+ author: 'Nikos Katsikanis',
+ },
+ {
+ title: 'Sed Do Eiusmod Insights',
+ url: '/blog/4-adipiscing',
+ author: 'Nikos Katsikanis',
+ },
+ {
+ title: 'Vanilla JS Patterns Release News',
+ url: '/blog/5-breaking-news',
+ author: 'Nikos Katsikanis',
+ },
+];
diff --git a/js/routes/blog/tags.js b/js/routes/blog/tags.js
new file mode 100644
index 0000000..5fc3ca7
--- /dev/null
+++ b/js/routes/blog/tags.js
@@ -0,0 +1,7 @@
+export const GENERAL = 'general';
+export const TUTORIAL = 'tutorial';
+export const NEWS = 'news';
+export const OPINION = 'opinion';
+export const OTHER = 'other';
+
+export const TAGS = [GENERAL, TUTORIAL, NEWS, OPINION, OTHER];
diff --git a/js/routes/contact.js b/js/routes/contact.js
new file mode 100644
index 0000000..4896e1d
--- /dev/null
+++ b/js/routes/contact.js
@@ -0,0 +1,34 @@
+// File: routes/contact.js
+// Purpose: A simple contact form allowing users to send a message
+
+export default (hostComponent) => {
+ hostComponent.innerHTML = '';
+
+ const indexHTML = `
+ Get in Touch
+ I’d love to hear from you! Whether it’s a question, feedback, or just to say hello, drop me a message below and I’ll reply as soon as I can.
+
+
+ `;
+
+ hostComponent.innerHTML = indexHTML;
+};
diff --git a/js/utils/canonicalUrl.js b/js/utils/canonicalUrl.js
new file mode 100644
index 0000000..78be5c2
--- /dev/null
+++ b/js/utils/canonicalUrl.js
@@ -0,0 +1,14 @@
+export default function canonicalUrl(href = location.href) {
+ try {
+ const url = new URL(href, location.origin);
+ if (!url.pathname.endsWith('.html')) {
+ if (!url.pathname.endsWith('/')) {
+ url.pathname += '/';
+ }
+ url.pathname += 'index.html';
+ }
+ return url.toString();
+ } catch {
+ return href;
+ }
+}
diff --git a/package.json b/package.json
index 2807496..250040b 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,8 @@
"start": "serve -s . -l 3000",
"test": "vitest run --coverage",
"test:watch": "vitest",
- "prettier": "prettier --write \"**/*.{js,json,ts,tsx,css,scss,html}\""
+ "prettier": "prettier --write \"**/*.{js,json,ts,tsx,css,scss,html}\"",
+ "build:blog": "node scripts/build-blog.mjs"
},
"repository": {
"type": "git",
diff --git a/scripts/build-blog.mjs b/scripts/build-blog.mjs
new file mode 100644
index 0000000..85015d8
--- /dev/null
+++ b/scripts/build-blog.mjs
@@ -0,0 +1,88 @@
+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 { POSTS } = await import(pathToFileURL(path.join(root, 'js', 'routes', 'blog', 'posts.js')));
+
+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 template = ({ title, description, image, url, content }) => `
+
+
+
+
${title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+for (const post of POSTS) {
+ const rel = post.url.replace(/^\//, '');
+ const mod = await import(pathToFileURL(path.join(root, 'js', 'routes', `${rel}.js`)));
+ const content = mod.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);