From 4bf68781d5fbd97e09ca1fa599a4684c8bdc5652 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 11:33:35 +0530 Subject: [PATCH 1/4] feat: link compared frameworks from the /compare pages Add one outbound link per comparison to the competitor's official site, rendered as the "WebJs vs " header eyebrow on each /compare/ page (nextjs.org, lit.dev, remix.run, astro.build, rubyonrails.org). Relevant, authoritative outbound links are a mild positive SEO signal and, more importantly, make the comparisons read as honest and well-cited rather than evasive. The competitor URL rides a new `link` frontmatter field threaded through the compare types and queries. The Remix 3 page also links its "beta preview" mention to the Remix 3 beta post. Links are follow (genuine editorial citations), open in a new tab, and carry rel="noopener noreferrer". The shared blog/compare markdown renderer now opens absolute-URL links in a new tab while internal links still navigate in place. --- compare/webjs-vs-astro.md | 1 + compare/webjs-vs-lit.md | 1 + compare/webjs-vs-nextjs.md | 1 + compare/webjs-vs-rails.md | 1 + compare/webjs-vs-remix.md | 3 ++- website/app/compare/[slug]/page.ts | 6 +++++- website/modules/blog/utils/render-post.ts | 9 +++++++-- website/modules/compare/queries/get-comparison.server.ts | 1 + .../modules/compare/queries/list-comparisons.server.ts | 1 + website/modules/compare/types.ts | 2 ++ 10 files changed, 22 insertions(+), 4 deletions(-) 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..f4602784f 100644 --- a/website/app/compare/[slug]/page.ts +++ b/website/app/compare/[slug]/page.ts @@ -44,7 +44,11 @@ export default async function ComparePage({ params }: { params: { slug: string }
-

WebJs vs ${c.competitor}

+

+ WebJs vs ${c.link + ? html`${c.competitor}` + : c.competitor} +

${c.title}

${c.tagline}

diff --git a/website/modules/blog/utils/render-post.ts b/website/modules/blog/utils/render-post.ts index bd805406b..28c6a0378 100644 --- a/website/modules/blog/utils/render-post.ts +++ b/website/modules/blog/utils/render-post.ts @@ -22,8 +22,13 @@ 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. + const external = /^https?:\/\//.test(u); + const attrs = external ? ' target="_blank" rel="noopener noreferrer"' : ''; + return `${t}`; + }); 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[]; From 274b2ee8adc7e82a5ce6c45a5290970ab8141075 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 11:38:42 +0530 Subject: [PATCH 2/4] fix: a11y cue, href escaping, and tests for compare outbound links Address self-review findings on the outbound-link change: - Append the site's visually-hidden "(opens in a new tab)" cue (the lib/links.ts NEW_TAB convention) to the compare eyebrow link and to external links in the shared markdown renderer, so screen readers announce the tab change. - Percent-escape any double quote in a markdown link URL so it cannot break out of the href attribute. - Add SSR/unit tests: the renderer's external-vs-internal branching (with the internal counterfactual) and the compare page's outbound eyebrow link. --- website/app/compare/[slug]/page.ts | 3 +- website/modules/blog/utils/render-post.ts | 9 +++-- website/test/ssr/compare-ssr.test.ts | 41 +++++++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 website/test/ssr/compare-ssr.test.ts diff --git a/website/app/compare/[slug]/page.ts b/website/app/compare/[slug]/page.ts index f4602784f..0c341b3d5 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] @@ -46,7 +47,7 @@ export default async function ComparePage({ params }: { params: { slug: string }

WebJs vs ${c.link - ? html`${c.competitor}` + ? html`${c.competitor}${NEW_TAB}` : c.competitor}

${c.title}

diff --git a/website/modules/blog/utils/render-post.ts b/website/modules/blog/utils/render-post.ts index 28c6a0378..c1cf2a1a2 100644 --- a/website/modules/blog/utils/render-post.ts +++ b/website/modules/blog/utils/render-post.ts @@ -24,10 +24,15 @@ function inline(s: string): string { .replace(/>/g, '>'); 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. + // 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'); const external = /^https?:\/\//.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"' : ''; - return `${t}`; + const cue = external ? ' (opens in a new tab)' : ''; + return `${t}${cue}`; }); out = out.replace(/`([^`]+)`/g, '$1'); out = out.replace(/\*\*([^\n]+?)\*\*/g, '$1'); diff --git a/website/test/ssr/compare-ssr.test.ts b/website/test/ssr/compare-ssr.test.ts new file mode 100644 index 000000000..d5634f73c --- /dev/null +++ b/website/test/ssr/compare-ssr.test.ts @@ -0,0 +1,41 @@ +/** + * 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'); + + // 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 links the competitor eyebrow to its official site in a new tab', async () => { + const out = await renderToString(await ComparePage({ params: { slug: 'webjs-vs-nextjs' } })); + // The "WebJs vs Next.js" eyebrow links out to nextjs.org, new tab, with cue. + assert.match(out, /]*target="_blank"[^>]*rel="noopener noreferrer"/, 'eyebrow links to the official site in a new tab'); + assert.match(out, /Next\.js/, 'renders the competitor name'); + assert.match(out, /\(opens in a new tab\)/, 'the outbound eyebrow link carries the screen-reader cue'); +}); From 09815e4e4dc84667f59efb148677c2c5b663b7c1 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 11:47:28 +0530 Subject: [PATCH 3/4] fix: treat uppercase and protocol-relative URLs as external links Broaden the new-tab detection in the shared markdown renderer so a case-insensitive scheme (HTTPS://) and a protocol-relative //host URL are classified as external (new tab + rel + a11y cue) instead of an in-place internal nav. Document the behavior in the renderer header and cover the uppercase-scheme case in the test. --- website/modules/blog/utils/render-post.ts | 8 ++++++-- website/test/ssr/compare-ssr.test.ts | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/website/modules/blog/utils/render-post.ts b/website/modules/blog/utils/render-post.ts index c1cf2a1a2..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 { @@ -27,7 +29,9 @@ function inline(s: string): string { // 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'); - const external = /^https?:\/\//.test(u); + // 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"' : ''; diff --git a/website/test/ssr/compare-ssr.test.ts b/website/test/ssr/compare-ssr.test.ts index d5634f73c..354f8a8d0 100644 --- a/website/test/ssr/compare-ssr.test.ts +++ b/website/test/ssr/compare-ssr.test.ts @@ -19,6 +19,10 @@ test('external markdown links open in a new tab with the a11y cue; internal link 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'); From 9dd189a2b86b51cdb9c7973663c0c93f314f1638 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 13:00:06 +0530 Subject: [PATCH 4/4] feat: make the compare outbound link visible, not hover-only The competitor link was the header eyebrow styled with no underline, so it only looked like a link on hover. Move it to an explicit, always- visible "Visit the site" link under the tagline: accent color, underlined, with an external-link arrow icon, so a reader knows it is a link while reading. The eyebrow returns to plain text. Update the SSR test to assert the visible, underlined outbound link. --- website/app/compare/[slug]/page.ts | 14 +++++++++----- website/test/ssr/compare-ssr.test.ts | 11 ++++++----- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/website/app/compare/[slug]/page.ts b/website/app/compare/[slug]/page.ts index 0c341b3d5..3a06f6d1b 100644 --- a/website/app/compare/[slug]/page.ts +++ b/website/app/compare/[slug]/page.ts @@ -45,13 +45,17 @@ export default async function ComparePage({ params }: { params: { slug: string }
-

- WebJs vs ${c.link - ? html`${c.competitor}${NEW_TAB}` - : c.competitor} -

+

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/test/ssr/compare-ssr.test.ts b/website/test/ssr/compare-ssr.test.ts index 354f8a8d0..93878dc1b 100644 --- a/website/test/ssr/compare-ssr.test.ts +++ b/website/test/ssr/compare-ssr.test.ts @@ -36,10 +36,11 @@ test('a double quote in a link URL cannot break out of the href attribute', () = assert.match(out, /%22/, 'the quote is percent-escaped in the href'); }); -test('the compare page links the competitor eyebrow to its official site in a new tab', async () => { +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' } })); - // The "WebJs vs Next.js" eyebrow links out to nextjs.org, new tab, with cue. - assert.match(out, /]*target="_blank"[^>]*rel="noopener noreferrer"/, 'eyebrow links to the official site in a new tab'); - assert.match(out, /Next\.js/, 'renders the competitor name'); - assert.match(out, /\(opens in a new tab\)/, 'the outbound eyebrow link carries the screen-reader cue'); + // 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'); });