Skip to content
Merged

Dev #89

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/create-module-playground.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@runablejs/cli": minor
---

Create a private workspace Runable playground that loads the module root when scaffolding a module.
5 changes: 5 additions & 0 deletions .changeset/include-parent-local-modules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"runable": patch
---

Include local modules located outside the application directory in the generated TypeScript configuration, and only inherit aliases from modules referenced by local paths.
5 changes: 5 additions & 0 deletions .changeset/use-starter-tsconfig-references.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@runablejs/cli": patch
---

Use root TypeScript project references in generated starters and keep server configuration in `tsconfig.node.json`.
92 changes: 85 additions & 7 deletions packages/cli/src/commands/create/module.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { mkdir } from "node:fs/promises";
import { resolve } from "node:path";
import { cp, mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

import * as p from "@clack/prompts";
import { consola } from "consola";

import {
exitOnCancel,
type BaseProjectAnswers,
copyAppTemplate,
handleSharedAnswers,
afterAnswer,
copyServerEntry,
getCliPackageVersion,
writeRunableConfig,
} from "./shared.js";

/** Answers collected for the "create a Runable module" flow: the shared answers plus the module's own identity (name and `configKey`). */
Expand Down Expand Up @@ -56,6 +59,78 @@ function printSummary(answers: ModuleProjectAnswers): void {
consola.info(` installDeps: ${answers.installDeps ? "yes" : "no"}`);
}

/** Creates the local Runable application used to develop and test a module. */
export async function createModulePlayground(
moduleDir: string,
options: {
moduleName: string;
framework: string;
createServerEntry: boolean;
},
) {
const __dirname = dirname(fileURLToPath(import.meta.url));
const sharedStarterDir = resolve(__dirname, "../../../starters/_shared");
const playgroundDir = resolve(moduleDir, "playground");

await mkdir(playgroundDir, { recursive: true });
await cp(resolve(sharedStarterDir, "app"), resolve(playgroundDir, "app"), {
recursive: true,
force: true,
});
await cp(
resolve(sharedStarterDir, "tsconfig.json"),
resolve(playgroundDir, "tsconfig.json"),
);
await cp(
resolve(sharedStarterDir, "tsconfig.node.json"),
resolve(playgroundDir, "tsconfig.node.json"),
);
await writeRunableConfig({ modules: [".."] }, { cwd: playgroundDir });
await copyServerEntry(
options.createServerEntry,
options.framework,
playgroundDir,
);

const version = await getCliPackageVersion();
const packageJson = {
name: `${options.moduleName.replace(/^@/, "").replace("/", "-")}-playground`,
private: true,
type: "module",
scripts: {
...(options.createServerEntry
? { dev: "runable prepare && tsx watch server.ts" }
: {}),
prepare: "runable prepare",
build: "runable build",
typecheck: "tsc --noEmit",
},
dependencies: {
[options.moduleName]: "*",
...(options.framework === "express" && options.createServerEntry
? { express: "^5.2.1" }
: {}),
runable: version,
vue: "^3.5.0",
"vue-router": "^5.2.0",
},
devDependencies: {
"@runablejs/cli": version,
...(options.framework === "express" && options.createServerEntry
? { "@types/express": "^5.0.6" }
: {}),
"@types/node": "^24.13.3",
tsx: "^4.23.12",
typescript: "^6.0.3",
},
};

await writeFile(
resolve(playgroundDir, "package.json"),
`${JSON.stringify(packageJson, null, 2)}\n`,
);
}

/**
* Runs the "create a Runable module" flow: asks for the module's identity,
* scaffolds a new directory named after it, copies the app template into
Expand All @@ -77,16 +152,19 @@ export async function handleModuleProject() {
const moduleDir = resolve(process.cwd(), moduleName);
await mkdir(moduleDir, { recursive: true });

const appDirResolved = resolve(moduleDir, answers.appDir);
await copyAppTemplate(appDirResolved);

// Record the config key alongside the other shared config values so it
// ends up in the module's generated runable.config.
Object.assign(answers._config, { configKey });

await createModulePlayground(moduleDir, { moduleName, ...answers });

// `isModule: true` tells `afterAnswer` to scaffold module-specific output
// (e.g. `defineModule` instead of `defineConfig`) rather than a regular project.
await afterAnswer(moduleDir, answers, { moduleName, isModule: true });
await afterAnswer(
moduleDir,
{ ...answers, createServerEntry: false },
{ moduleName, isModule: true },
);

// printSummary(answers);

Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/commands/create/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,9 +474,12 @@ export async function createPackageJson(
module: "./dist/index.js",
types: "./dist/index.d.ts",
files: ["dist"],
workspaces: ["playground"],
scripts: {
build: "runable build",
"app:prepare": "runable prepare",
"playground:prepare": "cd playground && runable prepare",
"playground:build": "cd playground && runable build",
},
devDependencies: {
"@runablejs/cli": version,
Expand Down Expand Up @@ -513,6 +516,20 @@ export async function configurePnpmBuilds(
const document = parseDocument(source);
if (document.errors.length > 0) throw document.errors[0];

try {
const packageJson = JSON.parse(
await readFile(resolve(cwd, "package.json"), "utf8"),
);
if (
document.get("packages") === undefined &&
Array.isArray(packageJson.workspaces)
) {
document.set("packages", packageJson.workspaces);
}
} catch {
// A package.json is optional for the existing-project flow.
}

const esbuildPermission = document.getIn(["allowBuilds", "esbuild"]);
if (esbuildPermission === undefined || esbuildPermission === null) {
document.setIn(["allowBuilds", "esbuild"], true);
Expand Down
7 changes: 0 additions & 7 deletions packages/cli/starters/_shared/app/tsconfig.json

This file was deleted.

17 changes: 5 additions & 12 deletions packages/cli/starters/_shared/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["**/*.ts", "**/*.vue"],
"exclude": ["node_modules", ".app", ".output"]
"files": [],
"references": [
{ "path": ".app/tsconfig.app.json" },
{ "path": "tsconfig.node.json" }
]
}

13 changes: 13 additions & 0 deletions packages/cli/starters/_shared/tsconfig.node.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["**/*.ts", "**/*.vue"],
"exclude": ["node_modules", ".app", ".output", "app"]
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"compilerOptions": {
"rootDir": "./",
"outDir": "./build"
}
},
"exclude": ["app"]
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@
"emitDecoratorMetadata": true,
"outDir": "dist"
},
"include": ["**/*.ts", "**/*.vue"],
"exclude": ["node_modules", ".app", ".output"]
"include": ["**/*.ts"],
"exclude": ["node_modules", ".app", ".output", "app"]
}
34 changes: 23 additions & 11 deletions packages/runable/src/config/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ type RawEntry = {
configFile?: string;
/** The raw config as returned by `c12Load`, before `resolveConfig` runs on it. */
loaded: ModuleDefinition;
/** Whether this module was referenced through a relative filesystem path. */
isLocalModule: boolean;
/** Names of the configs whose `modules` list references this one. Empty for the root. */
dependents: Set<string>;
/** Position in discovery (completion) order — carried over to `_index` on the resolved config. */
Expand Down Expand Up @@ -225,6 +227,7 @@ async function loadAllConfigs(
rawName: string,
cwd: string | undefined,
dependent?: string,
isLocalModule = false,
): Promise<RawEntry> {
const name = normalizeModuleName(rawName);

Expand All @@ -246,26 +249,29 @@ async function loadAllConfigs(
cwd: entryCwd,
configFile,
loaded: loaded ?? ({} as ModuleDefinition),
isLocalModule,
dependents: new Set(),
index: index++,
};

entries.set(resolvedName, entry);

const childModules = (entry.loaded.modules ?? []).map((childName) =>
resolveModuleName(childName, entryCwd),
);
const childModules = (entry.loaded.modules ?? []).map((childName) => ({
name: resolveModuleName(childName, entryCwd),
isLocal: childName.startsWith("."),
}));

for (const childName of childModules) {
addDependency(name, normalizeModuleName(childName));
for (const child of childModules) {
addDependency(name, normalizeModuleName(child.name));
}

await Promise.all(
childModules.map((childName) =>
childModules.map((child) =>
load(
childName,
getModuleDir(childName, entryCwd, moduleDirCache),
child.name,
getModuleDir(child.name, entryCwd, moduleDirCache),
resolvedName,
child.isLocal,
),
),
);
Expand All @@ -277,6 +283,9 @@ async function loadAllConfigs(
}

const entry = await promise;
if (isLocalModule) {
entry.isLocalModule = true;
}
moduleNameAliases?.set(normalizeDir(rawName), entry.name);
moduleNameAliases?.set(normalizeModuleName(rawName), entry.name);
if (dependent) entry.dependents.add(dependent);
Expand Down Expand Up @@ -338,6 +347,7 @@ function resolveAllConfigs(entries: Map<string, RawEntry>): {
rConfig._configFile = entry.configFile;
rConfig._isRunableModule = (entry.loaded as ResolvedConfig)
._isRunableModule as boolean;
rConfig._isLocalModule = entry.isLocalModule;
rConfig._dependents = [...entry.dependents];
rConfig._index = entry.index;

Expand Down Expand Up @@ -497,14 +507,16 @@ export interface ConfigGraph {
}

/**
* Combines aliases from the resolved config graph. Dependencies are applied
* first, then their parents, and the main application last, so the closest
* consumer wins when two configs declare the same alias.
* Combines aliases from the main application and its local modules. Installed
* packages keep their aliases private. Local dependencies are applied first,
* then their parents, and the main application last, so the closest consumer
* wins when two configs declare the same alias.
*/
function mergeConfigAliases(configs: ResolvedConfig[]) {
const aliases: ResolvedConfig["alias"] = {};

for (const config of [...configs].reverse()) {
if (config._isRunableModule && !config._isLocalModule) continue;
Object.assign(aliases, config.alias);
}

Expand Down
3 changes: 3 additions & 0 deletions packages/runable/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,9 @@ export type ResolvedConfig = {
/** `true` if this config belongs to a Runable module rather than the root application. */
_isRunableModule?: boolean;

/** `true` when the module was referenced through a relative filesystem path. */
_isLocalModule?: boolean;

/**
* A module's resolved options (`defaults` merged with the consumer's
* overrides) — only ever set for a config loaded on behalf of a `parent`
Expand Down
4 changes: 2 additions & 2 deletions packages/runable/src/utils/tsconfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const tsconfig = {
};

export function writeTsConfig() {
const { output, appDir, alias, _configFile, _cwd } = useConfig();
const { output, appDir, alias, _configFile } = useConfig();

Object.entries(alias ?? {}).forEach(([key, value]) => {
if (key === "#build") return;
Expand All @@ -55,7 +55,7 @@ export function writeTsConfig() {

for (const config of useAllConfigs()) {
if (!config._isRunableModule) continue;
if (!config._cwd.startsWith(_cwd)) continue;
if (!config._isLocalModule) continue;

tsconfig.app.addInclude(
normalizeDir(join(relative(output, config.appDir), "**/*")),
Expand Down
5 changes: 5 additions & 0 deletions playground/app/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Voluptates suscipit,
iste illum cum vel dolor veritatis nemo iusto deserunt minus eligendi optio
totam maiores dolores enim porro rerum labore possimus!
</template>
6 changes: 0 additions & 6 deletions playground/app/components/hello.global.vue

This file was deleted.

4 changes: 0 additions & 4 deletions playground/app/composables/test.ts

This file was deleted.

3 changes: 0 additions & 3 deletions playground/app/css/index.css

This file was deleted.

3 changes: 0 additions & 3 deletions playground/app/layouts/admin.vue

This file was deleted.

6 changes: 0 additions & 6 deletions playground/app/pages/hello/yes.vue

This file was deleted.

Loading
Loading