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 @@ + + + + + Understanding Lorem Ipsum + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +

Understanding Lorem Ipsum

+

Nikos Katsikanis - January 1, 2025

+
+

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.

+
+ +
+
+ + Fork me on GitHub + +
+
+ + + \ No newline at end of file diff --git a/blog/2-dolor-sit/index.html b/blog/2-dolor-sit/index.html new file mode 100644 index 0000000..796ac3d --- /dev/null +++ b/blog/2-dolor-sit/index.html @@ -0,0 +1,54 @@ + + + + + Dolor Sit Amet Explained + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +

Dolor Sit Amet Explained

+

Nikos Katsikanis - January 2, 2025

+
+

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.

+
+ +
+
+ + Fork me on GitHub + +
+
+ + + \ No newline at end of file diff --git a/blog/3-consectetur/index.html b/blog/3-consectetur/index.html new file mode 100644 index 0000000..620065e --- /dev/null +++ b/blog/3-consectetur/index.html @@ -0,0 +1,54 @@ + + + + + Consectetur Adipiscing Tips + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +

Consectetur Adipiscing Tips

+

Nikos Katsikanis - January 3, 2025

+
+

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.

+
+ +
+
+ + Fork me on GitHub + +
+
+ + + \ No newline at end of file diff --git a/blog/4-adipiscing/index.html b/blog/4-adipiscing/index.html new file mode 100644 index 0000000..2fc1de8 --- /dev/null +++ b/blog/4-adipiscing/index.html @@ -0,0 +1,54 @@ + + + + + Sed Do Eiusmod Insights + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +

Sed Do Eiusmod Insights

+

Nikos Katsikanis - January 4, 2025

+
+

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.

+
+ +
+
+ + Fork me on GitHub + +
+
+ + + \ No newline at end of file diff --git a/blog/5-breaking-news/index.html b/blog/5-breaking-news/index.html new file mode 100644 index 0000000..ff7b35c --- /dev/null +++ b/blog/5-breaking-news/index.html @@ -0,0 +1,54 @@ + + + + + Vanilla JS Patterns Release News + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +

Vanilla JS Patterns Release News

+

Nikos Katsikanis - May 1, 2025

+
+

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.

+
+ +
+
+ + Fork me on GitHub + +
+
+ + + \ No newline at end of file diff --git a/blog/index.html b/blog/index.html new file mode 100644 index 0000000..1ca208d --- /dev/null +++ b/blog/index.html @@ -0,0 +1,46 @@ + + + + + Blog + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ + Fork me on GitHub + +
+
+ + + \ No newline at end of file diff --git a/css/typography.css b/css/typography.css index e64a07f..7303ff6 100644 --- a/css/typography.css +++ b/css/typography.css @@ -1,7 +1,76 @@ h1 { font-size: 2rem; - margin: 20px 0; + margin: 2rem 0; +} +h2 { + margin: 1rem 0; +} +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: monospace; } .minor { color: var(--minor-text); } + +.blog-title { + font-family: monospace; + text-align: center; +} + +.blog-list a { + font-family: monospace; + display: block; + text-align: center; + font-size: 1.2rem; +} + +.post-nav { + display: flex; + justify-content: space-between; + margin-top: 2rem; + font-family: monospace; +} +.post-nav a { + flex: 1; +} +.post-nav .prev { + text-align: left; +} +.post-nav .next { + text-align: right; +} + +.subscribe-container { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin-top: 1rem; +} + +.share-buttons { + display: flex; + gap: 0.5rem; + margin-top: 1rem; +} +.share-buttons svg { + width: 24px; + height: 24px; + fill: currentColor; +} + +.embeddable-buttondown-form { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} +.embeddable-buttondown-form input[type="email"] { + flex: 1; + min-width: 200px; +} diff --git a/js/assets/socialIconData.js b/js/assets/socialIconData.js new file mode 100644 index 0000000..79d9fac --- /dev/null +++ b/js/assets/socialIconData.js @@ -0,0 +1,20 @@ +export const youtubeIcon = ``; +export const twitterIcon = ``; +export const twitchIcon = ``; +export const podcastIcon = ``; +export const etoroIcon = `eToroeToro`; +export const githubIcon = ``; +export const linkedinIcon = ``; +export const facebookIcon = ``; +export const redditIcon = ``; +export default { + youtubeIcon, + twitterIcon, + twitchIcon, + podcastIcon, + etoroIcon, + githubIcon, + linkedinIcon, + facebookIcon, + redditIcon, +}; diff --git a/js/components/blog-list.js b/js/components/blog-list.js new file mode 100644 index 0000000..a3860d5 --- /dev/null +++ b/js/components/blog-list.js @@ -0,0 +1,189 @@ +/** + * 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) || []; + + return { + title: post.title, + url: post.url, + author: post.author || mod?.author || 'Unknown', + image: imgEl ? imgEl.getAttribute('src') : null, + date: mod?.date instanceof Date ? mod.date : mod?.date ? new Date(mod.date) : new Date('1970-01-01'), + 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/js/components/blog-subscribe.js b/js/components/blog-subscribe.js new file mode 100644 index 0000000..3b99466 --- /dev/null +++ b/js/components/blog-subscribe.js @@ -0,0 +1,61 @@ +export default (hostComponent) => { + const container = document.createElement('div'); + container.className = 'subscribe-container'; + + const wrapper = document.createElement('div'); + wrapper.innerHTML = ` +
+ + + +

+ Powered by Buttondown. +

+
+ `; + const formEl = wrapper.firstElementChild; + + const supportsNotifications = + 'Notification' in window && !/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent); + + if (supportsNotifications) { + const notifBtn = document.createElement('button'); + notifBtn.className = 'wireframe'; + + function updateText() { + if (localStorage.getItem('notify-blog') === 'true') { + notifBtn.textContent = 'Thanks for enabling notifications'; + } else { + notifBtn.textContent = 'Enable Notifications'; + } + } + + updateText(); + + notifBtn.addEventListener('click', async () => { + if (localStorage.getItem('notify-blog') === 'true') { + localStorage.removeItem('notify-blog'); + updateText(); + return; + } + + const perm = await Notification.requestPermission(); + if (perm === 'granted') { + localStorage.setItem('notify-blog', 'true'); + updateText(); + } + }); + + container.append(notifBtn, formEl); + } else { + container.appendChild(formEl); + } + + hostComponent.appendChild(container); +}; diff --git a/js/components/discuss.js b/js/components/discuss.js new file mode 100644 index 0000000..9eda485 --- /dev/null +++ b/js/components/discuss.js @@ -0,0 +1,16 @@ +export default (hostComponent) => { + hostComponent.innerHTML = ''; + const commentsContainer = document.createElement('div'); + commentsContainer.id = 'comments'; + + const script = document.createElement('script'); + script.src = 'https://utteranc.es/client.js'; + script.setAttribute('repo', 'quantuminformation/vanillajs-patterns-blog-comments'); + script.setAttribute('issue-term', 'pathname'); + script.setAttribute('theme', 'github-light'); + script.crossOrigin = 'anonymous'; + script.async = true; + + hostComponent.appendChild(commentsContainer); + hostComponent.appendChild(script); +}; diff --git a/js/components/nav.js b/js/components/nav.js index 083765f..3d537c0 100644 --- a/js/components/nav.js +++ b/js/components/nav.js @@ -151,6 +151,10 @@ export default (hostComponent) => { 🏠 Home + + 📝 + Blog + 🔘 Button + Badges @@ -179,6 +183,10 @@ export default (hostComponent) => { 🍪 Cookie popup + + ✉️ + Contact + 🧮 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} + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ ${content} +
+
+
+ + +`; + +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);