diff --git a/compare/webjs-vs-astro.md b/compare/webjs-vs-astro.md index 24ead3b2a..657aa18dc 100644 --- a/compare/webjs-vs-astro.md +++ b/compare/webjs-vs-astro.md @@ -4,6 +4,7 @@ date: 2026-07-09T10:00:00+05:30 slug: webjs-vs-astro description: "An honest comparison of WebJs and Astro. Both ship little JavaScript by default and use an islands model, but WebJs is a no-build full-stack framework with server actions and a data layer, where Astro is a build-time content framework you add islands to." competitor: "Astro" +link: "https://astro.build" tagline: "Islands and near-zero JS, plus a full server-action and data story." tags: comparison, astro, islands, zero-js, no-build author: Vivek diff --git a/compare/webjs-vs-lit.md b/compare/webjs-vs-lit.md index 4547b8f73..ee527a011 100644 --- a/compare/webjs-vs-lit.md +++ b/compare/webjs-vs-lit.md @@ -4,6 +4,7 @@ date: 2026-07-09T10:00:00+05:30 slug: webjs-vs-lit description: "An honest comparison of WebJs and Lit. WebJs deliberately matches Lit's component API (html templates, reactive properties, lifecycle, directives) and wraps it in a full-stack framework with routing, SSR, server actions, and a client router, all with no build step." competitor: "Lit" +link: "https://lit.dev" tagline: "Lit's component model you already know, wrapped in a full-stack framework." tags: comparison, lit, web-components, ssr, no-build author: Vivek diff --git a/compare/webjs-vs-nextjs.md b/compare/webjs-vs-nextjs.md index a085ee1a8..7b695feae 100644 --- a/compare/webjs-vs-nextjs.md +++ b/compare/webjs-vs-nextjs.md @@ -4,6 +4,7 @@ date: 2026-07-09T10:00:00+05:30 slug: webjs-vs-nextjs description: "An honest comparison of WebJs and Next.js. WebJs keeps the Next-style developer experience (file routing, server actions, streaming SSR) but drops the build step and the React Server Component split, building the view layer on native web components instead." competitor: "Next.js" +link: "https://nextjs.org" tagline: "The Next.js developer experience, minus the build step and the RSC mental model." tags: comparison, nextjs, react, web-components, no-build author: Vivek diff --git a/compare/webjs-vs-rails.md b/compare/webjs-vs-rails.md index 7fcfffba1..6e4b644c9 100644 --- a/compare/webjs-vs-rails.md +++ b/compare/webjs-vs-rails.md @@ -4,6 +4,7 @@ date: 2026-07-09T10:00:00+05:30 slug: webjs-vs-rails description: "An honest comparison of WebJs and Ruby on Rails. Both refuse a build step and lean on importmaps and sensible defaults, but WebJs is one TypeScript language across the stack with web components and typed server actions, where Rails is Ruby with Hotwire." competitor: "Rails" +link: "https://rubyonrails.org" tagline: "The Rails no-build, sensible-defaults philosophy, in one TypeScript language." tags: comparison, rails, hotwire, importmap, no-build author: Vivek diff --git a/compare/webjs-vs-remix.md b/compare/webjs-vs-remix.md index 9fa263bb7..0524f771b 100644 --- a/compare/webjs-vs-remix.md +++ b/compare/webjs-vs-remix.md @@ -4,6 +4,7 @@ date: 2026-07-09T10:00:00+05:30 slug: webjs-vs-remix description: "An honest comparison of WebJs and Remix 3. Both drop the bundler, move beyond React, run on web standards, and are built for AI agents. They diverge on the view layer: WebJs uses native web components with a declarative reactive API, Remix 3 uses its own runtime-first virtual DOM with an imperative model." competitor: "Remix 3" +link: "https://remix.run" tagline: "Two frameworks that dropped the bundler and moved beyond React, diverging on the view layer." tags: comparison, remix, remix-3, web-standards, no-build author: Vivek @@ -48,7 +49,7 @@ WebJs uses one server-action boundary. A `.server.ts` file with `'use server'` e # Difference four: stability today -Remix 3 is in beta preview. Its ideas are compelling and its direction is close to WebJs's, but the API is still moving, and building on it today means building on a moving target. This is a factual note about where Remix 3 is in its cycle, worth weighing if you need to ship on a stable surface now. +Remix 3 is in [beta preview](https://remix.run/blog/remix-3-beta-preview). Its ideas are compelling and its direction is close to WebJs's, but the API is still moving, and building on it today means building on a moving target. This is a factual note about where Remix 3 is in its cycle, worth weighing if you need to ship on a stable surface now. # Where Remix 3 is the better pick diff --git a/website/app/compare/[slug]/page.ts b/website/app/compare/[slug]/page.ts index a6091e8b0..3a06f6d1b 100644 --- a/website/app/compare/[slug]/page.ts +++ b/website/app/compare/[slug]/page.ts @@ -1,6 +1,7 @@ import { html, unsafeHTML, notFound } from '@webjsdev/core'; import { getComparison } from '#modules/compare/queries/get-comparison.server.ts'; import { renderPostBody } from '#modules/blog/utils/render-post.ts'; +import { NEW_TAB } from '#lib/links.ts'; /** * /compare/[slug] @@ -47,6 +48,14 @@ export default async function ComparePage({ params }: { params: { slug: string }

WebJs vs ${c.competitor}

${c.title}

${c.tagline}

+ ${c.link + ? html`

+ + Visit the ${c.competitor} site + ${NEW_TAB} + +

` + : ''}
${unsafeHTML(renderPostBody(c.body))}
diff --git a/website/modules/blog/utils/render-post.ts b/website/modules/blog/utils/render-post.ts index bd805406b..8cf55856a 100644 --- a/website/modules/blog/utils/render-post.ts +++ b/website/modules/blog/utils/render-post.ts @@ -14,7 +14,9 @@ * - `> ` blockquotes * - `- ` bulleted lists (custom-positioned markers via `before:` pseudo-element) * - ```fenced``` code blocks - * - Inline: **bold**, *italic*, `code`, [text](url) + * - Inline: **bold**, *italic*, `code`, [text](url). An absolute or + * protocol-relative URL opens in a new tab (with a screen-reader cue); + * an internal `/path` link navigates in place. */ function inline(s: string): string { @@ -22,8 +24,20 @@ function inline(s: string): string { .replace(/&/g, '&') .replace(//g, '>'); - out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, t, u) => - `${t}`); + out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, t, u) => { + // Absolute (off-site) links open in a new tab so the reader keeps the + // article; internal links (starting with /) navigate in place. Escape any + // `"` in the URL so it cannot break out of the href attribute. + const href = u.replace(/"/g, '%22'); + // External = an absolute http(s) URL (scheme is case-insensitive) or a + // protocol-relative `//host`. Internal `/path` links stay same-tab. + const external = /^(https?:)?\/\//i.test(u); + // For a new-tab link, append the same visually-hidden cue the site chrome + // uses (lib/links.ts NEW_TAB) so screen readers announce the tab change. + const attrs = external ? ' target="_blank" rel="noopener noreferrer"' : ''; + const cue = external ? ' (opens in a new tab)' : ''; + return `${t}${cue}`; + }); out = out.replace(/`([^`]+)`/g, '$1'); out = out.replace(/\*\*([^\n]+?)\*\*/g, '$1'); out = out.replace(/(^|[^\w])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[^\w])/g, '$1$2'); diff --git a/website/modules/compare/queries/get-comparison.server.ts b/website/modules/compare/queries/get-comparison.server.ts index 3ca4085fb..16264774c 100644 --- a/website/modules/compare/queries/get-comparison.server.ts +++ b/website/modules/compare/queries/get-comparison.server.ts @@ -39,6 +39,7 @@ export async function getComparison(slug: string): Promise t.trim()).filter(Boolean), author: fm.author || 'Vivek', diff --git a/website/modules/compare/queries/list-comparisons.server.ts b/website/modules/compare/queries/list-comparisons.server.ts index 647dc156c..f0dc54d5a 100644 --- a/website/modules/compare/queries/list-comparisons.server.ts +++ b/website/modules/compare/queries/list-comparisons.server.ts @@ -44,6 +44,7 @@ export async function listComparisons(): Promise { date: fm.date || '', description: fm.description || '', competitor: fm.competitor, + link: fm.link || '', tagline: fm.tagline, tags: (fm.tags || '').split(',').map((t) => t.trim()).filter(Boolean), author: fm.author || 'Vivek', diff --git a/website/modules/compare/types.ts b/website/modules/compare/types.ts index 260828f53..8b8e4747a 100644 --- a/website/modules/compare/types.ts +++ b/website/modules/compare/types.ts @@ -5,6 +5,8 @@ export type Comparison = { description: string; /** The framework being compared against, e.g. "Next.js". */ competitor: string; + /** Canonical URL of the competitor's official site (outbound link). */ + link: string; /** One-line hook shown on the index card. */ tagline: string; tags: string[]; diff --git a/website/test/ssr/compare-ssr.test.ts b/website/test/ssr/compare-ssr.test.ts new file mode 100644 index 000000000..93878dc1b --- /dev/null +++ b/website/test/ssr/compare-ssr.test.ts @@ -0,0 +1,46 @@ +/** + * Tests for the /compare outbound-link behavior (#856). + * + * Two surfaces: the shared markdown renderer's absolute-vs-internal link + * branching, and the compare [slug] page's competitor eyebrow link. Both + * open off-site links in a new tab with the site's screen-reader cue, and + * keep internal links navigating in place. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { renderToString } from '@webjsdev/core/server'; +import { renderPostBody } from '#modules/blog/utils/render-post.ts'; +import ComparePage from '#app/compare/[slug]/page.ts'; + +test('external markdown links open in a new tab with the a11y cue; internal links do not', () => { + const external = renderPostBody('See the [Remix 3 beta](https://remix.run/blog/remix-3-beta-preview).'); + assert.match(external, /href="https:\/\/remix\.run\/blog\/remix-3-beta-preview"/, 'keeps the external href'); + assert.match(external, /target="_blank"/, 'external link opens in a new tab'); + assert.match(external, /rel="noopener noreferrer"/, 'external link carries rel noopener noreferrer'); + assert.match(external, /\(opens in a new tab\)/, 'external link appends the screen-reader cue'); + + // An uppercase scheme is still external (schemes are case-insensitive). + const upper = renderPostBody('[x](HTTPS://example.com)'); + assert.match(upper, /target="_blank"/, 'uppercase-scheme link is treated as external'); + + // Counterfactual: an internal link must stay same-tab (no target/cue). + const internal = renderPostBody('Read the [docs](/docs).'); + assert.match(internal, /href="\/docs"/, 'keeps the internal href'); + assert.doesNotMatch(internal, /target="_blank"/, 'internal link stays in place'); + assert.doesNotMatch(internal, /opens in a new tab/, 'internal link gets no new-tab cue'); +}); + +test('a double quote in a link URL cannot break out of the href attribute', () => { + const out = renderPostBody('[x](https://e.com/?q="bad")'); + assert.doesNotMatch(out, /\?q="bad"/, 'the raw quote is not emitted inside the attribute'); + assert.match(out, /%22/, 'the quote is percent-escaped in the href'); +}); + +test('the compare page shows a visible outbound link to the competitor site in a new tab', async () => { + const out = await renderToString(await ComparePage({ params: { slug: 'webjs-vs-nextjs' } })); + // A visible, underlined "Visit the Next.js site" link (distinguishable while + // reading, not hover-only), pointing at nextjs.org, new tab, with the cue. + assert.match(out, /]*target="_blank"[^>]*rel="noopener noreferrer"[^>]*class="[^"]*underline/, 'renders an underlined outbound link'); + assert.match(out, /Visit the Next\.js site/, 'the link is clearly labeled'); + assert.match(out, /\(opens in a new tab\)/, 'the outbound link carries the screen-reader cue'); +});