From 5bdf0f90257d574e64871f8fbd1135298ad2c414 Mon Sep 17 00:00:00 2001 From: ZJ van de Weg Date: Mon, 8 Jun 2026 11:56:01 -0700 Subject: [PATCH 1/7] nuxt: Migrate Blog overview page from 11ty --- nuxt/nuxt.config.ts | 20 +++++ nuxt/pages/blog/[[page]].vue | 130 +++++++++++++++++++++++++++++++ nuxt/server/api/blog.get.ts | 130 +++++++++++++++++++++++++++++++ nuxt/server/middleware/legacy.ts | 4 + package-lock.json | 2 +- 5 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 nuxt/pages/blog/[[page]].vue create mode 100644 nuxt/server/api/blog.get.ts diff --git a/nuxt/nuxt.config.ts b/nuxt/nuxt.config.ts index 2c78dd4e4e..de3b3b9dc8 100644 --- a/nuxt/nuxt.config.ts +++ b/nuxt/nuxt.config.ts @@ -17,6 +17,25 @@ function collectHandbookRoutes(dir: string, basePath: string): string[] { return routes } +// Collect blog listing routes for SSG prerendering (/blog/, /blog/2/, …) +function collectBlogRoutes(): string[] { + const blogDir = join(__dirname, '../src/blog') + let count = 0 + function countMd(dir: string) { + for (const file of readdirSync(dir)) { + const full = join(dir, file) + if (statSync(full).isDirectory()) countMd(full) + else if (file.endsWith('.md')) count++ + } + } + countMd(blogDir) + const pageCount = Math.ceil(count / 19) + return [ + '/blog/', + ...Array.from({ length: pageCount - 1 }, (_, i) => `/blog/${i + 2}/`), + ] +} + // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ devtools: { enabled: true }, @@ -113,6 +132,7 @@ export default defineNuxtConfig({ '/whitepaper/accelerating-industrial-innovation-with-low-code-platforms/', '/resources/publications/', ...collectHandbookRoutes(join(__dirname, 'content/handbook'), '/handbook'), + ...collectBlogRoutes(), ], crawlLinks: false } diff --git a/nuxt/pages/blog/[[page]].vue b/nuxt/pages/blog/[[page]].vue new file mode 100644 index 0000000000..5af1a24d7d --- /dev/null +++ b/nuxt/pages/blog/[[page]].vue @@ -0,0 +1,130 @@ + + + diff --git a/nuxt/server/api/blog.get.ts b/nuxt/server/api/blog.get.ts new file mode 100644 index 0000000000..21610b884d --- /dev/null +++ b/nuxt/server/api/blog.get.ts @@ -0,0 +1,130 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { resolve, relative, basename } from 'node:path' + +const PAGE_SIZE = 19 + +// Normalise YAML dates ("2022-03-17 1:00:00.0", "2026-04-24") to ISO strings. +function normalizeDate(s: string): string { + const m = String(s).trim().match(/^(\d{4}-\d{2}-\d{2})(?:\s+(\d{1,2}):(\d{2}):(\d{2}))?/) + if (!m) return s + if (!m[2]) return `${m[1]}T00:00:00.000Z` + return `${m[1]}T${m[2].padStart(2, '0')}:${m[3]}:${m[4]}.000Z` +} + +export interface BlogPost { + title: string + date: string + url: string + description: string + image: string | null + authors: Array<{ id: string; name: string }> +} + +function parseFrontmatter(src: string): Record { + const m = src.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!m) return {} + + const result: Record = {} + const lines = m[1].split('\n') + let i = 0 + + while (i < lines.length) { + const line = lines[i] + if (!line.trim() || line.trim().startsWith('#')) { i++; continue } + + if (!/^\s/.test(line)) { + const kv = line.match(/^([a-zA-Z][\w-]*):\s*(.*)$/) + if (kv) { + const key = kv[1] + const val = kv[2].trim() + + if (!val) { + // Block sequence: collect indented "- item" lines + const items: string[] = [] + while (i + 1 < lines.length && /^\s+-\s/.test(lines[i + 1])) { + i++ + items.push(lines[i].trim().replace(/^-\s+/, '').replace(/^["']|["']$/g, '')) + } + result[key] = items + } else if (val.startsWith('[')) { + // Inline sequence: ["item1", "item2"] + result[key] = val.slice(1, -1) + .split(',') + .map(s => s.trim().replace(/^["']|["']$/g, '')) + .filter(Boolean) + } else if (val.startsWith('"') || val.startsWith("'")) { + result[key] = val.slice(1, -1) + } else { + result[key] = val + } + } + } + i++ + } + + return result +} + +function loadPeople(srcDir: string): Record { + const people: Record = {} + for (const subdir of ['_data/team', '_data/guests']) { + try { + const fullDir = resolve(srcDir, subdir) + for (const file of readdirSync(fullDir)) { + if (!file.endsWith('.json')) continue + const data = JSON.parse(readFileSync(resolve(fullDir, file), 'utf-8')) + people[basename(file, '.json')] = data.name + } + } catch { + // directory may not exist + } + } + return people +} + +function collectPosts(dir: string, base: string): Array> { + const posts: Array> = [] + for (const entry of readdirSync(dir)) { + const full = resolve(dir, entry) + if (statSync(full).isDirectory()) { + posts.push(...collectPosts(full, base)) + } else if (entry.endsWith('.md')) { + const content = readFileSync(full, 'utf-8') + const data = parseFrontmatter(content) + if (!data.title || !data.date) continue + const rel = relative(base, full).replace(/\\/g, '/') + data.url = `/blog/${rel.replace(/\.md$/, '/')}` + posts.push(data) + } + } + return posts +} + +export default defineEventHandler((event) => { + const query = getQuery(event) + const page = Math.max(1, Number(query.page) || 1) + + const srcDir = resolve(process.cwd(), '../src') + const blogDir = resolve(srcDir, 'blog') + const people = loadPeople(srcDir) + + const now = new Date() + const all = collectPosts(blogDir, blogDir) + .map(p => ({ ...p, _date: normalizeDate(String(p.date)) })) + .filter(p => process.env.NODE_ENV !== 'production' || new Date(p._date) <= now) + .sort((a, b) => new Date(b._date).getTime() - new Date(a._date).getTime()) + + const total = all.length + const pageCount = Math.ceil(total / PAGE_SIZE) + + const posts: BlogPost[] = all.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE).map(p => ({ + title: String(p.title), + date: p._date, + url: String(p.url), + description: String(p.description || ''), + image: p.image ? String(p.image) : null, + authors: ((p.authors as string[]) || []).map(id => ({ id, name: people[id] || id })), + })) + + return { posts, total, pageCount } +}) diff --git a/nuxt/server/middleware/legacy.ts b/nuxt/server/middleware/legacy.ts index 1838cc3c88..65068d6bc8 100644 --- a/nuxt/server/middleware/legacy.ts +++ b/nuxt/server/middleware/legacy.ts @@ -27,6 +27,10 @@ export default defineEventHandler(async (event) => { // Let Nuxt handle migrated path prefixes if (NUXT_PREFIXES.some(prefix => normalised === prefix || normalised.startsWith(prefix + '/'))) return + // Blog listing and paginated pages (/blog/, /blog/2/, /blog/3/, …) are served by Nuxt. + // Tag pages (/blog/how-to/, /blog/ai/, etc.) and individual posts still proxy to 11ty. + if (normalised === '/blog' || /^\/blog\/\d+$/.test(normalised)) return + // Proxy everything else to the 11ty dev server return proxyRequest(event, `http://localhost:8080${path}`) }) diff --git a/package-lock.json b/package-lock.json index ebc638634d..eb755cd2b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6947,7 +6947,7 @@ }, "node_modules/@parcel/watcher-wasm/node_modules/napi-wasm": { "version": "1.1.0", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT" }, From 2320630f762d36a493c0ff40078ac0ea56b3303f Mon Sep 17 00:00:00 2001 From: ZJ van de Weg Date: Mon, 8 Jun 2026 12:07:03 -0700 Subject: [PATCH 2/7] nuxt: Restore newsletter banner and fix blog images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring back the HubSpot email signup banner after the featured post on the blog listing page. Restructure the template to split featured post, banner, and regular posts into separate sections so all 18 grid posts render correctly. Replace NuxtImg with plain img tags for blog thumbnails — the Netlify image provider generates /.netlify/images URLs that 11ty cannot serve in dev, while plain src paths are correctly proxied to the 11ty dev server. --- nuxt/pages/blog/[[page]].vue | 103 +++++++++++++++++++---------------- 1 file changed, 57 insertions(+), 46 deletions(-) diff --git a/nuxt/pages/blog/[[page]].vue b/nuxt/pages/blog/[[page]].vue index 5af1a24d7d..598c426ed8 100644 --- a/nuxt/pages/blog/[[page]].vue +++ b/nuxt/pages/blog/[[page]].vue @@ -53,58 +53,69 @@ const nextHref = computed(() => `/blog/${currentPage.value + 1}/`)
    -