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
5 changes: 5 additions & 0 deletions .changeset/preserve-remote-css-imports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"runable": patch
---

Preserve remote CSS imports when building Runable modules instead of resolving their URLs as local files.
1 change: 1 addition & 0 deletions packages/runable/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
"magic-string": "catalog:",
"mlly": "catalog:",
"ofetch": "catalog:",
"postcss-import": "catalog:",
"syt": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
Expand Down
4 changes: 3 additions & 1 deletion packages/runable/src/vite/build/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ export async function buildModule() {
unbundle: true,
minify: false,

css: {},
// PostCSS keeps remote `@import` URLs external. Lightning CSS attempts
// to resolve them as local files while bundling.
css: { transformer: "postcss" },

plugins: [
Vue(),
Expand Down
350 changes: 129 additions & 221 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ catalog:

tsdown: ^0.22.14
"@tsdown/css": ^0.22.14
postcss-import: ^16.0.0
tsx: ^4.23.12
npm-run-all2: ^9.0.2

Expand Down
56 changes: 56 additions & 0 deletions tests/regressions/module-css-imports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { existsSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";

import {
cleanupFixtureDir,
createFixtureDir,
linkWorkspacePackage,
runInFixture,
writeFixtureFile,
} from "../fixtures.js";

describe("module CSS imports", () => {
it("preserves remote @import URLs when building a module", () => {
const directory = createFixtureDir("module-remote-css-import-");

try {
linkWorkspacePackage(directory, "runable", "packages/runable");
writeFixtureFile(
directory,
"runable.config.ts",
`import { defineModule } from "runable";

export default defineModule({ css: ["./app/css/main.css"] });
`,
);
writeFixtureFile(
directory,
"app/css/main.css",
`@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap");

.example { font-family: Inter, sans-serif; }
`,
);

const result = runInFixture(
directory,
`import { build } from "runable";
await build();
`,
{ timeout: 30_000 },
);

expect(result.status, result.stderr || result.stdout).toBe(0);
const cssDir = path.join(directory, "dist/app/css");
expect(existsSync(cssDir)).toBe(true);
const cssFile = readdirSync(cssDir).find((file) => file.endsWith(".css"));
expect(cssFile).toBeDefined();
expect(readFileSync(path.join(cssDir, cssFile!), "utf8")).toContain(
"https://fonts.googleapis.com/css2?family=Inter",
);
} finally {
cleanupFixtureDir(directory);
}
}, 30_000);
});
13 changes: 10 additions & 3 deletions website/app/pages/blog/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,16 @@ function formatArticleDate(article: ResolvedPageEntry) {
}).format(date);
}

const { data: articles, pending } = useAsyncData("blog:index", () =>
queryCollection("blog").all(),
);
const { data: articles, pending } = useAsyncData("blog:index", async () => {
const entries = await queryCollection("blog").all();

return [...entries].sort((first, second) => {
const firstDate = getArticleDate(first)?.getTime() ?? 0;
const secondDate = getArticleDate(second)?.getTime() ?? 0;

return secondDate - firstDate;
});
});

useHead({
title: "Blog",
Expand Down
56 changes: 54 additions & 2 deletions website/app/pages/changelog.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,64 @@
<script setup lang="ts">
const releases = [
{
version: "1.2.0",
date: "September 20, 2026",
title: "A complete module development workspace",
description:
"Module scaffolding now creates a minimal publishable package with a complete Runable playground for developing and testing the integration in place.",
current: true,
groups: [
{
type: "Modules",
tone: "accent",
items: [
"Generated modules are structured as minimal publishable workspace packages.",
"Each generated module includes a complete private Runable playground that loads the module directly from its workspace root.",
"Local module configuration files are included in the generated TypeScript project.",
],
},
{
type: "Improved",
tone: "success",
items: [
"The built-in welcome screen now fills its container without inheriting the browser's default body margin.",
],
},
],
},
{
version: "1.1.0",
date: "September 19, 2026",
title: "Better local module development",
description:
"This release improves TypeScript resolution and gives locally developed modules an isolated application for testing their complete Runable integration.",
current: false,
groups: [
{
type: "Modules",
tone: "accent",
items: [
"Local modules located outside the application directory are included in the generated TypeScript configuration.",
"Aliases are inherited only from modules referenced through local paths.",
"Module scaffolding creates a private workspace playground connected to the module root.",
],
},
{
type: "Starters",
tone: "neutral",
items: [
"Generated starters use root TypeScript project references and keep server configuration in tsconfig.node.json.",
],
},
],
},
{
version: "1.0.1",
date: "September 19, 2026",
title: "Module aliases and cleaner starters",
description:
"This patch improves module integration and ensures newly generated projects include the files expected from their chosen server framework.",
current: true,
current: false,
groups: [
{
type: "Fixed",
Expand Down Expand Up @@ -211,7 +263,7 @@ useSeoMeta({
Current release
</p>
<p class="mt-2 font-display text-2xl font-semibold">
v1.0.1
v1.2.0
</p>
</div>
<span class="relative mt-1 flex size-2">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
---
title: "Runable 1.0: All the Vue Conventions. Your Server Runtime."
description: Runable reaches its first stable release with file-system routing, SSR, data fetching, modules, generated types, and freedom to keep your backend.
date: 2026-09-09
cover: /images/blog/v1dot0.png
authors:
- domutala
---

Runable 1.0 is now available.

This release marks the first stable version of a Vue framework built around one idea: adopting a productive frontend framework should not require replacing the server architecture that already fits your application.

Runable brings file-system routing, layouts, middleware, server-side rendering, data fetching, plugins, modules, auto-imports, and generated types to Vue. Express, Fastify, NestJS, AdonisJS, Hono, Koa, Bun, Deno, or a custom server remains in charge of the backend.

## Why Runable exists

Vue and Vite provide an excellent foundation for building interfaces. As an application grows, teams often assemble the same additional pieces: a route convention, layouts, middleware, SSR, hydration, data loading, head management, generated types, and an extension system.

A meta-framework solves that coordination problem, but it commonly brings its own server runtime. That is a good default for many projects. It becomes a constraint when the backend is already a deliberate architectural choice.

Your NestJS application may organize a large domain. Your Fastify server may be tuned for a specific workload. Your AdonisJS project may already own authentication, validation, queues, and database access. An established Express service may carry years of middleware and operational knowledge.

Runable does not ask you to recreate that work elsewhere. It adds the Vue application layer to the server you chose.

```text
Your backend + Runable + Vue
↓ ↓ ↓
HTTP and APIs Conventions Interface
```

The backend continues to own API routes, authentication, business logic, infrastructure, and deployment. Runable handles the conventions required to build and render the Vue application.

## A complete Vue application layer

Runable 1.0 turns the application directory into a readable description of the frontend:

```text
app/
├── components/
├── composables/
├── layouts/
├── middleware/
├── pages/
├── plugins/
└── app.vue
```

Files in `app/pages/` become Vue Router routes. Layouts provide reusable application shells. Middleware attaches navigation behavior to pages. Components and composables are discovered automatically, while generated declarations keep those APIs visible to TypeScript and your editor.

The conventions remove repeated configuration without hiding Vue. You can still use Vue Router, Vue plugins, lifecycle hooks, provide/inject, and the rest of the Vue ecosystem directly.

## Server rendering without moving the backend

Each supported runtime receives an adapter shaped for its own API. An Express application uses regular Express middleware:

```ts
import Express from "express";
import { express } from "runable/adapters/express";

const server = Express();

server.get("/api/health", (_request, response) => {
response.json({ status: "ok" });
});

server.use(express());

server.listen(3000);
```

The API route remains an Express route. Runable receives the requests left for the frontend and renders the matching Vue page.

The same boundary applies to Fastify, Hono, Koa, NestJS, AdonisJS, Bun, and Deno. Projects with a specialized server can use the lower-level Node or Web Request primitives.

In development, the adapter connects the host server to Vite. In production, it serves the generated assets and renders the built application. The server remains the process your team starts, monitors, and deploys.

## Data that crosses the SSR boundary

Server rendering is most useful when the browser can continue from the state produced on the server. Runable's async data APIs coordinate that handoff.

```vue
<script setup lang="ts">
const { data: projects, pending, error } = await useAsyncData(
"projects",
(signal) => $fetch("/api/projects", { signal }),
);
</script>

<template>
<p v-if="pending">Loading…</p>
<p v-else-if="error">{{ error.message }}</p>
<ProjectList v-else :projects="projects" />
</template>
```

During SSR, Runable waits for the request, stores its result, and renders the page. The cache is serialized into the response and restored before hydration, so the browser does not immediately request the same data again.

`useFetch()` builds on the same system for reactive requests, transformations, key selection, deduplication, timeouts, manual execution, and cache control.

## Extensions at the right level

Runable has two complementary extension points.

Plugins initialize each Vue application instance. Use them to install Vue libraries, register directives, provide services, or connect lifecycle hooks.

Modules package broader conventions. A module can contribute configuration, plugins, components, composables, layouts, middleware, styles, and other modules. Module options receive generated types, and module-owned paths resolve from the module itself.

Configuration hooks can extend the resolved configuration and the complete route tree. This gives integrations room to participate in code generation without requiring applications to patch framework internals.

## Tooling that explains the generated application

Conventions are most useful when developers can inspect what they produce. Runable generates its application files in `.app/`, including the route table, auto-import declarations, module options, and TypeScript configuration.

The public Inspector API exposes the same resolved project model to tools. It can report routes, layouts, middleware, plugins, modules, auto-imports, and configuration as serializable data without modifying the project.

The CLI supports project preparation and production builds, and it can create applications for the supported server frameworks. Runable also ships Agent Skills and an MCP integration so coding agents can work from the framework's actual conventions instead of guessing from generic Vue patterns.

## Stable does not mean finished

Version 1.0 establishes the public foundation: the application structure, adapter boundary, rendering pipeline, extension model, data APIs, generated types, and tooling surface are ready for real projects.

There is still a great deal to build. The module ecosystem will grow. Runtime integrations will become deeper. Documentation, diagnostics, and developer tools will keep improving through feedback from applications using Runable in different server environments.

Stability means that this work now has a dependable base.

## Start with the server you want

Install Runable alongside Vue, Vue Router, and the backend framework used by your project:

```bash
pnpm add runable vue vue-router express
pnpm add -D @runablejs/cli tsx typescript @types/node @types/express
```

Create `app/pages/index.vue`, connect the matching adapter after your API routes, and start the server as usual.

Follow <a href="/docs/getting-started/installation.md">Installation</a> for the complete setup, continue with the <a href="/docs/getting-started/quickstart.md">Quick Start</a>, or compare the architecture in <a href="/docs/getting-started/vs-nuxt.md">Runable vs Nuxt</a>.

Runable 1.0 is the framework experience for teams that want all the Vue conventions—and their own server runtime.
Binary file added website/public/images/blog/v1dot0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading