From b2899dbce3e556e60c621ebf597ad61da28f117b Mon Sep 17 00:00:00 2001 From: David Viejo Date: Sat, 14 Mar 2026 21:05:25 +0100 Subject: [PATCH 1/5] feat: add blog with introductory post Add blog infrastructure with file-based posts and a listing page. First post: "Introducing PortZero: Why I Built a New Local Dev Proxy" covering the motivation, comparison with Ngrok/Portless/Nginx, and what PortZero does differently. Blog link added to navbar and footer. --- apps/web/src/app/blog/[slug]/page.tsx | 71 +++++ apps/web/src/app/blog/layout.tsx | 62 +++++ apps/web/src/app/blog/page.tsx | 58 +++++ .../app/blog/posts/introducing-portzero.tsx | 243 ++++++++++++++++++ apps/web/src/app/page.tsx | 12 + apps/web/src/lib/blog.ts | 32 +++ 6 files changed, 478 insertions(+) create mode 100644 apps/web/src/app/blog/[slug]/page.tsx create mode 100644 apps/web/src/app/blog/layout.tsx create mode 100644 apps/web/src/app/blog/page.tsx create mode 100644 apps/web/src/app/blog/posts/introducing-portzero.tsx create mode 100644 apps/web/src/lib/blog.ts diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..bb74257 --- /dev/null +++ b/apps/web/src/app/blog/[slug]/page.tsx @@ -0,0 +1,71 @@ +import { notFound } from "next/navigation"; +import { getAllPosts, getPost } from "@/lib/blog"; +import type { Metadata } from "next"; + +export function generateStaticParams() { + return getAllPosts().map((post) => ({ slug: post.slug })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + const post = getPost(slug); + if (!post) return {}; + return { + title: `${post.title} - PortZero Blog`, + description: post.description, + openGraph: { + title: post.title, + description: post.description, + type: "article", + publishedTime: post.date, + authors: [post.author], + }, + }; +} + +export default async function BlogPost({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const post = getPost(slug); + if (!post) notFound(); + + const PostContent = (await import(`../posts/${slug}`)).default; + + return ( +
+
+
+ + · + {post.readingTime} +
+

+ {post.title} +

+

+ {post.description} +

+
+
+ DV +
+ {post.author} +
+
+ +
+ ); +} diff --git a/apps/web/src/app/blog/layout.tsx b/apps/web/src/app/blog/layout.tsx new file mode 100644 index 0000000..fc93d5a --- /dev/null +++ b/apps/web/src/app/blog/layout.tsx @@ -0,0 +1,62 @@ +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; + +export default function BlogLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ +
+
{children}
+
+
+
+ + + Back to PortZero + +
+
+
+ ); +} diff --git a/apps/web/src/app/blog/page.tsx b/apps/web/src/app/blog/page.tsx new file mode 100644 index 0000000..40a6475 --- /dev/null +++ b/apps/web/src/app/blog/page.tsx @@ -0,0 +1,58 @@ +import Link from "next/link"; +import { getAllPosts } from "@/lib/blog"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Blog - PortZero", + description: + "Updates, tutorials, and behind-the-scenes from the PortZero project.", +}; + +export default function BlogIndex() { + const posts = getAllPosts(); + + return ( + <> +

Blog

+

+ Updates, tutorials, and behind-the-scenes from the PortZero project. +

+ +
+ {posts.map((post) => ( +
+ +
+ + · + {post.readingTime} +
+

+ {post.title} +

+

+ {post.description} +

+
+ {post.tags.map((tag) => ( + + {tag} + + ))} +
+ +
+ ))} +
+ + ); +} diff --git a/apps/web/src/app/blog/posts/introducing-portzero.tsx b/apps/web/src/app/blog/posts/introducing-portzero.tsx new file mode 100644 index 0000000..b1e6649 --- /dev/null +++ b/apps/web/src/app/blog/posts/introducing-portzero.tsx @@ -0,0 +1,243 @@ +import { CodeBlock } from "@/components/code-block"; + +export default async function IntroducingPortZero() { + return ( +
+

The Problem

+

+ If you work on more than one microservice at a time, you know the drill. + You spin up a frontend on :3000, an API on{" "} + :8080, maybe a worker on :9090. You juggle + ports, forget which service runs where, and when something breaks you + have no idea what request caused it. +

+

+ Your browser history is full of localhost:3000,{" "} + localhost:3001, localhost:8080. You mix them up + between projects. And if you need someone else to see your local work? + Time to set up a tunnel. +

+

+ I hit this problem daily. I tried the existing tools. Each solved part of + it, but none solved all of it. So I built PortZero. +

+ +

What I Was Using Before

+ +

Nginx / Caddy / Traefik

+

+ Great reverse proxies — for production. For local dev, they're + overkill. You need config files, you need to manage them separately from + your services, and they don't know anything about your dev + processes. No traffic inspection, no process management, no hot-reload + awareness. They're designed for deployed infrastructure, not for{" "} + pnpm dev. +

+ +

Ngrok

+

+ Ngrok is excellent at what it does: exposing local ports to the internet. + But it's a tunnel, not a local dev environment tool. You still need + to manage ports yourself, it doesn't give you stable local URLs + across projects, and there's no built-in traffic inspection, + mocking, or process management. It solves the "show this to a + teammate" problem, not the "I have 5 services running + locally" problem. +

+ +

Portless (Vercel)

+

+ + Portless + {" "} + tackles the same core problem: giving your local dev servers human-readable + URLs instead of random port numbers. It's a CLI tool from the Vercel + ecosystem that maps .local domains to your services. +

+

+ Portless validated the problem — port juggling is real and developers + hate it. But it's CLI-only with no visual dashboard, no traffic + inspection, no request replay or mocking, and no process management. I + wanted something that went further: not just stable URLs, but a full + local dev companion that lets you see, debug, and simulate everything + flowing through your services. +

+ +

What PortZero Does Differently

+

+ PortZero is a single Rust binary that combines five things that usually + require separate tools: +

+
    +
  1. + Reverse proxy — route{" "} + <app>.localhost to your local ports, powered by + Cloudflare Pingora +
  2. +
  3. + Process manager — spawn, monitor, and auto-restart + your dev services with deterministic port assignment +
  4. +
  5. + Traffic inspector — capture every HTTP request and + response with full headers and bodies, persisted to SQLite +
  6. +
  7. + Request replay & mocking — re-send captured + requests with overrides, or create mock responses without hitting + upstream +
  8. +
  9. + Network simulation — inject latency, packet loss, and + bandwidth throttling per-app to test degraded conditions +
  10. +
+ +

Here's what it looks like in practice:

+ + + +

No config files. No port numbers to remember. Just names.

+ +

Why Rust?

+

+ A dev proxy sits in the hot path of every HTTP request you make during + development. It needs to be fast enough that you forget it's there. + Rust gives us that — sub-millisecond proxy overhead — plus a single + static binary with no runtime dependencies. +

+

+ We built on top of{" "} + + Cloudflare Pingora + + , the same proxy framework that handles a significant chunk of + internet traffic at Cloudflare. It gives us HTTP/1, HTTP/2, and WebSocket + support out of the box, with battle-tested connection pooling and TLS. +

+ +

The Desktop App

+

+ One thing that sets PortZero apart is the native desktop app, built with{" "} + + Tauri v2 + + . While the CLI gives you full control, the desktop app makes traffic + inspection and mocking visual and immediate: +

+
    +
  • See all running apps with CPU, memory, and uptime at a glance
  • +
  • Browse and filter every HTTP request flowing through the proxy
  • +
  • Create mock responses with a form instead of writing JSON
  • +
  • Configure network simulation per-app with sliders
  • +
  • Manage the daemon from the system tray
  • +
+

+ This is the biggest gap I saw in tools like Portless — when you're + debugging a tricky API interaction, you want to see the + requests, not grep through logs. A visual dashboard turns traffic + inspection from a chore into something you actually use. +

+ +

Multi-App Config

+

+ For projects with multiple services, PortZero supports a{" "} + portzero.toml config file: +

+ + + +

+ Run portzero up and all three services start with stable + URLs. Run portzero down to stop them all. That's it. +

+ +

What's Next

+

PortZero is open source and actively developed. Coming soon:

+
    +
  • + Public tunnels — expose local apps to the internet + with a single command, built on QUIC +
  • +
  • + Windows support — currently macOS and Linux only +
  • +
  • + API schema inference — automatically detect and + document your API's shape from captured traffic +
  • +
  • + Team sharing — share mock configurations and traffic + snapshots with your team +
  • +
+ +

Try It

+ + +

+ Check out the{" "} + + GitHub repo + + , read the{" "} + docs, or{" "} + download the desktop app. +

+
+ ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index feaa7c4..d337e03 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -57,6 +57,12 @@ function Navbar() { > Docs + + Blog + Docs + + Blog + new Date(b.date).getTime() - new Date(a.date).getTime() + ); +} + +export function getPost(slug: string): BlogPost | undefined { + return posts.find((p) => p.slug === slug); +} From a2cd930b2be79eb46bec2fb56e299eb1b4ba6285 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Sat, 14 Mar 2026 21:09:18 +0100 Subject: [PATCH 2/5] fix: update mermaid to 11.13.0 to resolve dompurify XSS vulnerability --- apps/web/package.json | 2 +- apps/web/pnpm-lock.yaml | 137 +++++++++++++++++++++------------------- 2 files changed, 74 insertions(+), 65 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 3719e63..2210dcd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "lucide-react": "^0.468.0", - "mermaid": "^11.12.3", + "mermaid": "^11.13.0", "next": "^15.2.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index ad2fb38..9b32178 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^0.468.0 version: 0.468.0(react@19.2.4) mermaid: - specifier: ^11.12.3 - version: 11.12.3 + specifier: ^11.13.0 + version: 11.13.0 next: specifier: ^15.2.0 version: 15.5.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -58,20 +58,20 @@ packages: '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} - '@chevrotain/cst-dts-gen@11.1.1': - resolution: {integrity: sha512-fRHyv6/f542qQqiRGalrfJl/evD39mAvbJLCekPazhiextEatq1Jx1K/i9gSd5NNO0ds03ek0Cbo/4uVKmOBcw==} + '@chevrotain/cst-dts-gen@11.1.2': + resolution: {integrity: sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==} - '@chevrotain/gast@11.1.1': - resolution: {integrity: sha512-Ko/5vPEYy1vn5CbCjjvnSO4U7GgxyGm+dfUZZJIWTlQFkXkyym0jFYrWEU10hyCjrA7rQtiHtBr0EaZqvHFZvg==} + '@chevrotain/gast@11.1.2': + resolution: {integrity: sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==} - '@chevrotain/regexp-to-ast@11.1.1': - resolution: {integrity: sha512-ctRw1OKSXkOrR8VTvOxrQ5USEc4sNrfwXHa1NuTcR7wre4YbjPcKw+82C2uylg/TEwFRgwLmbhlln4qkmDyteg==} + '@chevrotain/regexp-to-ast@11.1.2': + resolution: {integrity: sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==} - '@chevrotain/types@11.1.1': - resolution: {integrity: sha512-wb2ToxG8LkgPYnKe9FH8oGn3TMCBdnwiuNC5l5y+CtlaVRbCytU0kbVsk6CGrqTL4ZN4ksJa0TXOYbxpbthtqw==} + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} - '@chevrotain/utils@11.1.1': - resolution: {integrity: sha512-71eTYMzYXYSFPrbg/ZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+FE1TVQ==} + '@chevrotain/utils@11.1.2': + resolution: {integrity: sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==} '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} @@ -235,8 +235,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@mermaid-js/parser@1.0.0': - resolution: {integrity: sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==} + '@mermaid-js/parser@1.0.1': + resolution: {integrity: sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==} '@next/env@15.5.12': resolution: {integrity: sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==} @@ -523,6 +523,9 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -545,8 +548,8 @@ packages: peerDependencies: chevrotain: ^11.0.0 - chevrotain@11.1.1: - resolution: {integrity: sha512-f0yv5CPKaFxfsPTBzX7vGuim4oIC1/gcS7LUGdBSwl2dU6+FON6LVUksdOo1qJjoUvXNn45urgh8C+0a24pACQ==} + chevrotain@11.1.2: + resolution: {integrity: sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==} client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -727,11 +730,11 @@ packages: resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} engines: {node: '>=12'} - dagre-d3-es@7.0.13: - resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==} + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} - dayjs@1.11.19: - resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} delaunator@5.0.1: resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} @@ -747,8 +750,8 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - dompurify@3.3.1: - resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + dompurify@3.3.3: + resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} enhanced-resolve@5.19.0: resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} @@ -784,8 +787,8 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - katex@0.16.29: - resolution: {integrity: sha512-ef+wYUDehNgScWoA0ZhEngsNqUv9uIj4ftd/PapQmT+E85lXI6Wx6BvJO48v80Vhj3t/IjEoZWw9/ZPe8kHwHg==} + katex@0.16.38: + resolution: {integrity: sha512-cjHooZUmIAUmDsHBN+1n8LaZdpmbj03LtYeYPyuYB7OuloiaeaV6N4LcfjcnHVzGWjVQmKrxxTrpDcmSzEZQwQ==} hasBin: true khroma@2.1.0: @@ -890,8 +893,8 @@ packages: mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} - mermaid@11.12.3: - resolution: {integrity: sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ==} + mermaid@11.13.0: + resolution: {integrity: sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==} micromark-util-character@2.1.1: resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} @@ -908,8 +911,8 @@ packages: micromark-util-types@2.0.2: resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} - mlly@1.8.0: - resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + mlly@1.8.1: + resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} @@ -1053,8 +1056,8 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} - tinyexec@1.0.2: - resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} engines: {node: '>=18'} trim-lines@3.0.1: @@ -1133,26 +1136,26 @@ snapshots: '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 - tinyexec: 1.0.2 + tinyexec: 1.0.4 '@braintree/sanitize-url@7.1.2': {} - '@chevrotain/cst-dts-gen@11.1.1': + '@chevrotain/cst-dts-gen@11.1.2': dependencies: - '@chevrotain/gast': 11.1.1 - '@chevrotain/types': 11.1.1 + '@chevrotain/gast': 11.1.2 + '@chevrotain/types': 11.1.2 lodash-es: 4.17.23 - '@chevrotain/gast@11.1.1': + '@chevrotain/gast@11.1.2': dependencies: - '@chevrotain/types': 11.1.1 + '@chevrotain/types': 11.1.2 lodash-es: 4.17.23 - '@chevrotain/regexp-to-ast@11.1.1': {} + '@chevrotain/regexp-to-ast@11.1.2': {} - '@chevrotain/types@11.1.1': {} + '@chevrotain/types@11.1.2': {} - '@chevrotain/utils@11.1.1': {} + '@chevrotain/utils@11.1.2': {} '@emnapi/runtime@1.8.1': dependencies: @@ -1165,7 +1168,7 @@ snapshots: dependencies: '@antfu/install-pkg': 1.1.0 '@iconify/types': 2.0.0 - mlly: 1.8.0 + mlly: 1.8.1 '@img/colour@1.0.0': optional: true @@ -1283,7 +1286,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@mermaid-js/parser@1.0.0': + '@mermaid-js/parser@1.0.1': dependencies: langium: 4.2.1 @@ -1565,6 +1568,11 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + acorn@8.16.0: {} caniuse-lite@1.0.30001772: {} @@ -1575,18 +1583,18 @@ snapshots: character-entities-legacy@3.0.0: {} - chevrotain-allstar@0.3.1(chevrotain@11.1.1): + chevrotain-allstar@0.3.1(chevrotain@11.1.2): dependencies: - chevrotain: 11.1.1 + chevrotain: 11.1.2 lodash-es: 4.17.23 - chevrotain@11.1.1: + chevrotain@11.1.2: dependencies: - '@chevrotain/cst-dts-gen': 11.1.1 - '@chevrotain/gast': 11.1.1 - '@chevrotain/regexp-to-ast': 11.1.1 - '@chevrotain/types': 11.1.1 - '@chevrotain/utils': 11.1.1 + '@chevrotain/cst-dts-gen': 11.1.2 + '@chevrotain/gast': 11.1.2 + '@chevrotain/regexp-to-ast': 11.1.2 + '@chevrotain/types': 11.1.2 + '@chevrotain/utils': 11.1.2 lodash-es: 4.17.23 client-only@0.0.1: {} @@ -1788,12 +1796,12 @@ snapshots: d3-transition: 3.0.1(d3-selection@3.0.0) d3-zoom: 3.0.0 - dagre-d3-es@7.0.13: + dagre-d3-es@7.0.14: dependencies: d3: 7.9.0 lodash-es: 4.17.23 - dayjs@1.11.19: {} + dayjs@1.11.20: {} delaunator@5.0.1: dependencies: @@ -1807,7 +1815,7 @@ snapshots: dependencies: dequal: 2.0.3 - dompurify@3.3.1: + dompurify@3.3.3: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -1850,7 +1858,7 @@ snapshots: jiti@2.6.1: {} - katex@0.16.29: + katex@0.16.38: dependencies: commander: 8.3.0 @@ -1858,8 +1866,8 @@ snapshots: langium@4.2.1: dependencies: - chevrotain: 11.1.1 - chevrotain-allstar: 0.3.1(chevrotain@11.1.1) + chevrotain: 11.1.2 + chevrotain-allstar: 0.3.1(chevrotain@11.1.2) vscode-languageserver: 9.0.1 vscode-languageserver-textdocument: 1.0.12 vscode-uri: 3.1.0 @@ -1941,21 +1949,22 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 - mermaid@11.12.3: + mermaid@11.13.0: dependencies: '@braintree/sanitize-url': 7.1.2 '@iconify/utils': 3.1.0 - '@mermaid-js/parser': 1.0.0 + '@mermaid-js/parser': 1.0.1 '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 cytoscape: 3.33.1 cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1) cytoscape-fcose: 2.2.0(cytoscape@3.33.1) d3: 7.9.0 d3-sankey: 0.12.3 - dagre-d3-es: 7.0.13 - dayjs: 1.11.19 - dompurify: 3.3.1 - katex: 0.16.29 + dagre-d3-es: 7.0.14 + dayjs: 1.11.20 + dompurify: 3.3.3 + katex: 0.16.38 khroma: 2.1.0 lodash-es: 4.17.23 marked: 16.4.2 @@ -1981,7 +1990,7 @@ snapshots: micromark-util-types@2.0.2: {} - mlly@1.8.0: + mlly@1.8.1: dependencies: acorn: 8.16.0 pathe: 2.0.3 @@ -2032,7 +2041,7 @@ snapshots: pkg-types@1.3.1: dependencies: confbox: 0.1.8 - mlly: 1.8.0 + mlly: 1.8.1 pathe: 2.0.3 points-on-curve@0.2.0: {} @@ -2154,7 +2163,7 @@ snapshots: tapable@2.3.0: {} - tinyexec@1.0.2: {} + tinyexec@1.0.4: {} trim-lines@3.0.1: {} From a12d9f4db00afd40a000e5b00f37486418d47a67 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Sat, 14 Mar 2026 21:14:02 +0100 Subject: [PATCH 3/5] feat: add JSON-LD schema and Twitter Card meta to blog posts Add BlogPosting, Person, Organization, and BreadcrumbList JSON-LD structured data. Author links to x.com/davidviejodev. Add Twitter Card meta tags, canonical URLs, and OG siteName/url/modifiedTime. Author name in post header now links to X profile. --- apps/web/src/app/blog/[slug]/page.tsx | 78 ++++++++++++------ apps/web/src/components/blog-post-json-ld.tsx | 82 +++++++++++++++++++ 2 files changed, 133 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/components/blog-post-json-ld.tsx diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx index bb74257..0345494 100644 --- a/apps/web/src/app/blog/[slug]/page.tsx +++ b/apps/web/src/app/blog/[slug]/page.tsx @@ -1,5 +1,6 @@ import { notFound } from "next/navigation"; import { getAllPosts, getPost } from "@/lib/blog"; +import { BlogPostJsonLd } from "@/components/blog-post-json-ld"; import type { Metadata } from "next"; export function generateStaticParams() { @@ -17,13 +18,26 @@ export async function generateMetadata({ return { title: `${post.title} - PortZero Blog`, description: post.description, + alternates: { + canonical: `https://goport0.dev/blog/${post.slug}`, + }, openGraph: { title: post.title, description: post.description, + url: `https://goport0.dev/blog/${post.slug}`, + siteName: "PortZero", type: "article", publishedTime: post.date, + modifiedTime: post.date, authors: [post.author], }, + twitter: { + card: "summary_large_image", + title: post.title, + description: post.description, + creator: "@davidviejodev", + site: "@davidviejodev", + }, }; } @@ -39,33 +53,43 @@ export default async function BlogPost({ const PostContent = (await import(`../posts/${slug}`)).default; return ( - + + + + ); } diff --git a/apps/web/src/components/blog-post-json-ld.tsx b/apps/web/src/components/blog-post-json-ld.tsx new file mode 100644 index 0000000..64b3105 --- /dev/null +++ b/apps/web/src/components/blog-post-json-ld.tsx @@ -0,0 +1,82 @@ +import type { BlogPost } from "@/lib/blog"; + +export function BlogPostJsonLd({ post }: { post: BlogPost }) { + const url = `https://goport0.dev/blog/${post.slug}`; + + const jsonLd = { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "BlogPosting", + "@id": `${url}#article`, + headline: post.title, + description: post.description, + datePublished: post.date, + dateModified: post.date, + author: { + "@type": "Person", + "@id": "https://goport0.dev/#author", + name: "David Viejo", + url: "https://x.com/davidviejodev", + sameAs: ["https://x.com/davidviejodev"], + }, + publisher: { + "@type": "Organization", + "@id": "https://goport0.dev/#organization", + name: "PortZero", + url: "https://goport0.dev", + logo: { + "@type": "ImageObject", + url: "https://goport0.dev/icon.svg", + }, + }, + mainEntityOfPage: { + "@type": "WebPage", + "@id": url, + }, + keywords: post.tags.join(", "), + wordCount: parseInt(post.readingTime) * 200, + inLanguage: "en-US", + isPartOf: { + "@type": "Blog", + "@id": "https://goport0.dev/blog#blog", + name: "PortZero Blog", + publisher: { + "@id": "https://goport0.dev/#organization", + }, + }, + }, + { + "@type": "BreadcrumbList", + "@id": `${url}#breadcrumb`, + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: "Home", + item: "https://goport0.dev", + }, + { + "@type": "ListItem", + position: 2, + name: "Blog", + item: "https://goport0.dev/blog", + }, + { + "@type": "ListItem", + position: 3, + name: post.title, + item: url, + }, + ], + }, + ], + }; + + return ( +