Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions compare/webjs-vs-astro.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions compare/webjs-vs-lit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions compare/webjs-vs-nextjs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions compare/webjs-vs-rails.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion compare/webjs-vs-remix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions website/app/compare/[slug]/page.ts
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -47,6 +48,14 @@ export default async function ComparePage({ params }: { params: { slug: string }
<p class="font-mono text-[12px] uppercase tracking-[0.14em] text-accent font-semibold mb-[20px]">WebJs vs ${c.competitor}</p>
<h1 class="font-serif text-[clamp(36px,6vw,56px)] leading-[1.05] tracking-tight text-fg m-0 mb-[24px]">${c.title}</h1>
<p class="text-fg-muted text-[19px] leading-[1.55] m-0 font-serif italic">${c.tagline}</p>
${c.link
? html`<p class="mt-[20px] m-0">
<a href=${c.link} target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-[6px] font-mono text-[13px] text-accent underline underline-offset-4 decoration-accent/40 hover:decoration-accent transition-colors">
Visit the ${c.competitor} site
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7"/><path d="M7 7h10v10"/></svg>${NEW_TAB}
</a>
</p>`
: ''}
</header>

<article class="mt-[16px]">${unsafeHTML(renderPostBody(c.body))}</article>
Expand Down
20 changes: 17 additions & 3 deletions website/modules/blog/utils/render-post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,30 @@
* - `> ` 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 {
let out = s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, t, u) =>
`<a href="${u}" class="text-accent no-underline hover:underline" rel="noopener noreferrer">${t}</a>`);
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);
Comment thread
vivek7405 marked this conversation as resolved.
// For a new-tab link, append the same visually-hidden cue the site chrome
Comment thread
vivek7405 marked this conversation as resolved.
// uses (lib/links.ts NEW_TAB) so screen readers announce the tab change.
const attrs = external ? ' target="_blank" rel="noopener noreferrer"' : '';
Comment thread
vivek7405 marked this conversation as resolved.
const cue = external ? '<span class="sr-only"> (opens in a new tab)</span>' : '';
return `<a href="${href}" class="text-accent no-underline hover:underline"${attrs}>${t}${cue}</a>`;
});
out = out.replace(/`([^`]+)`/g, '<code class="font-mono text-[0.9em] bg-bg-subtle text-fg px-[6px] py-[2px] rounded">$1</code>');
out = out.replace(/\*\*([^\n]+?)\*\*/g, '<strong class="font-semibold text-fg">$1</strong>');
out = out.replace(/(^|[^\w])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[^\w])/g, '$1<em>$2</em>');
Expand Down
1 change: 1 addition & 0 deletions website/modules/compare/queries/get-comparison.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export async function getComparison(slug: string): Promise<ComparisonWithBody |
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',
Expand Down
1 change: 1 addition & 0 deletions website/modules/compare/queries/list-comparisons.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export async function listComparisons(): Promise<Comparison[]> {
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',
Expand Down
2 changes: 2 additions & 0 deletions website/modules/compare/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
46 changes: 46 additions & 0 deletions website/test/ssr/compare-ssr.test.ts
Original file line number Diff line number Diff line change
@@ -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, /<a href="https:\/\/nextjs\.org"[^>]*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');
});
Loading