From 49eb471f95c82853886426d47d472f9659a18050 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 8 Apr 2026 14:52:01 -0400 Subject: [PATCH] Playground build tool --- .../playground/cmd/typespec-playground.js | 2 + packages/playground/package.json | 13 +- packages/playground/src/cli/build.ts | 45 +++++ packages/playground/src/cli/cli.ts | 81 +++++++++ .../playground/src/cli/create-vite-config.ts | 40 +++++ packages/playground/src/cli/dev.ts | 47 +++++ .../playground/src/cli/resolve-libraries.ts | 65 +++++++ .../playground/src/cli/virtual-app-plugin.ts | 161 ++++++++++++++++++ .../test/cli/resolve-libraries.test.ts | 111 ++++++++++++ packages/playground/vite.config.ts | 7 + pnpm-lock.yaml | 21 ++- 11 files changed, 584 insertions(+), 9 deletions(-) create mode 100755 packages/playground/cmd/typespec-playground.js create mode 100644 packages/playground/src/cli/build.ts create mode 100644 packages/playground/src/cli/cli.ts create mode 100644 packages/playground/src/cli/create-vite-config.ts create mode 100644 packages/playground/src/cli/dev.ts create mode 100644 packages/playground/src/cli/resolve-libraries.ts create mode 100644 packages/playground/src/cli/virtual-app-plugin.ts create mode 100644 packages/playground/test/cli/resolve-libraries.test.ts diff --git a/packages/playground/cmd/typespec-playground.js b/packages/playground/cmd/typespec-playground.js new file mode 100755 index 00000000000..56614c6a9fd --- /dev/null +++ b/packages/playground/cmd/typespec-playground.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import "../dist/cli/cli.js"; diff --git a/packages/playground/package.json b/packages/playground/package.json index 9b88378a812..c75a1de92ad 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -18,6 +18,9 @@ ], "type": "module", "main": "dist/src/index.js", + "bin": { + "typespec-playground": "cmd/typespec-playground.js" + }, "exports": { ".": { "types": "./dist/src/index.d.ts", @@ -68,6 +71,7 @@ }, "files": [ "lib/*.tsp", + "cmd/**", "dist/**", "!dist/test/**" ], @@ -83,17 +87,21 @@ "@typespec/protobuf": "workspace:^", "@typespec/rest": "workspace:^", "@typespec/versioning": "workspace:^", + "@vitejs/plugin-react": "catalog:", "clsx": "catalog:", "debounce": "catalog:", "lzutf8": "catalog:", "monaco-editor": "catalog:", + "picocolors": "catalog:", "react": "catalog:", "react-dom": "catalog:", "react-error-boundary": "catalog:", "swagger-ui-dist": "catalog:", + "vite": "catalog:", "vscode-languageserver": "catalog:", "vscode-languageserver-textdocument": "catalog:", - "yaml": "catalog:" + "yaml": "catalog:", + "yargs": "catalog:" }, "devDependencies": { "@babel/core": "catalog:", @@ -108,16 +116,15 @@ "@types/react": "catalog:", "@types/react-dom": "catalog:", "@types/swagger-ui-dist": "catalog:", + "@types/yargs": "catalog:", "@typespec/bundler": "workspace:^", "@typespec/react-components": "workspace:^", - "@vitejs/plugin-react": "catalog:", "c8": "catalog:", "cross-env": "catalog:", "es-module-shims": "catalog:", "rimraf": "catalog:", "storybook": "catalog:", "typescript": "catalog:", - "vite": "catalog:", "vite-plugin-checker": "catalog:", "vite-plugin-dts": "catalog:", "vitest": "catalog:" diff --git a/packages/playground/src/cli/build.ts b/packages/playground/src/cli/build.ts new file mode 100644 index 00000000000..b4de2b5c988 --- /dev/null +++ b/packages/playground/src/cli/build.ts @@ -0,0 +1,45 @@ +/* eslint-disable no-console */ +import pc from "picocolors"; +import { build } from "vite"; +import { createPlaygroundViteConfig } from "./create-vite-config.js"; +import { resolveLibraries } from "./resolve-libraries.js"; + +export interface BuildOptions { + readonly emitter: string; + readonly output: string; + readonly libraries?: string[]; +} + +export async function runBuild(options: BuildOptions): Promise { + const projectRoot = process.cwd(); + const allLibraries = resolveLibraries(options.emitter, projectRoot); + + // Add any extra libraries specified via CLI + if (options.libraries) { + for (const lib of options.libraries) { + if (!allLibraries.includes(lib)) { + allLibraries.push(lib); + } + } + } + + console.log(pc.cyan("TypeSpec Playground Build")); + console.log(pc.dim(`Emitter: ${options.emitter}`)); + console.log(pc.dim(`Libraries: ${allLibraries.join(", ")}`)); + console.log(pc.dim(`Output: ${options.output}`)); + console.log(); + + const viteConfig = createPlaygroundViteConfig({ + defaultEmitter: options.emitter, + libraries: allLibraries, + outputDir: options.output, + }); + + await build({ + ...viteConfig, + root: projectRoot, + }); + + console.log(); + console.log(pc.green(`✓ Playground built to ${options.output}`)); +} diff --git a/packages/playground/src/cli/cli.ts b/packages/playground/src/cli/cli.ts new file mode 100644 index 00000000000..f28a9110010 --- /dev/null +++ b/packages/playground/src/cli/cli.ts @@ -0,0 +1,81 @@ +/* eslint-disable no-console */ +import pc from "picocolors"; +import yargs from "yargs"; +import { runBuild } from "./build.js"; +import { runDev } from "./dev.js"; + +async function main() { + console.log(pc.cyan("TypeSpec Playground CLI\n")); + + await yargs(process.argv.slice(2)) + .scriptName("typespec-playground") + .help() + .strict() + .parserConfiguration({ + "greedy-arrays": false, + "boolean-negation": false, + }) + .option("emitter", { + type: "string", + description: "The emitter package name (e.g. @typespec/openapi3).", + demandOption: true, + }) + .option("libraries", { + type: "array", + string: true, + description: + "Additional TypeSpec libraries to include beyond auto-discovered peer dependencies.", + }) + .command( + "dev", + "Start a development server with hot-reload for the playground.", + (cmd) => { + return cmd.option("port", { + type: "number", + description: "Port for the dev server.", + default: 5174, + }); + }, + async (args) => { + await runDev({ + emitter: args.emitter, + port: args.port, + libraries: args.libraries, + }); + }, + ) + .command( + "build", + "Create a production build of the playground.", + (cmd) => { + return cmd.option("output", { + type: "string", + description: "Output directory for the build.", + default: "dist/playground", + }); + }, + async (args) => { + await runBuild({ + emitter: args.emitter, + output: args.output, + libraries: args.libraries, + }); + }, + ) + .demandCommand(1, "You must use one of the supported commands.").argv; +} + +function internalError(error: unknown): never { + console.error(pc.red("Internal error!")); + console.error("File issue at https://github.com/microsoft/typespec"); + console.error(); + console.error(error); + process.exit(1); +} + +process.on("unhandledRejection", (error: unknown) => { + console.error("Unhandled promise rejection!"); + internalError(error); +}); + +main().catch(internalError); diff --git a/packages/playground/src/cli/create-vite-config.ts b/packages/playground/src/cli/create-vite-config.ts new file mode 100644 index 00000000000..3813a642f35 --- /dev/null +++ b/packages/playground/src/cli/create-vite-config.ts @@ -0,0 +1,40 @@ +import type { UserConfig } from "vite"; +import { definePlaygroundViteConfig } from "../vite/index.js"; +import { playgroundAppPlugin } from "./virtual-app-plugin.js"; + +export interface PlaygroundViteOptions { + /** The default emitter to select in the playground. */ + readonly defaultEmitter: string; + /** Full list of TypeSpec libraries to bundle. */ + readonly libraries: readonly string[]; + /** Title for the playground page. */ + readonly title?: string; + /** Output directory for production build. */ + readonly outputDir?: string; +} + +/** + * Create a full Vite config for the standalone playground CLI. + * Composes the base playground config with the virtual app plugin. + */ +export function createPlaygroundViteConfig(options: PlaygroundViteOptions): UserConfig { + const config = definePlaygroundViteConfig({ + defaultEmitter: options.defaultEmitter, + libraries: options.libraries, + }); + + config.plugins!.push( + playgroundAppPlugin({ + title: options.title, + }), + ); + + if (options.outputDir) { + config.build = { + ...config.build, + outDir: options.outputDir, + }; + } + + return config; +} diff --git a/packages/playground/src/cli/dev.ts b/packages/playground/src/cli/dev.ts new file mode 100644 index 00000000000..ffe30a31aa8 --- /dev/null +++ b/packages/playground/src/cli/dev.ts @@ -0,0 +1,47 @@ +/* eslint-disable no-console */ +import pc from "picocolors"; +import { createServer } from "vite"; +import { createPlaygroundViteConfig } from "./create-vite-config.js"; +import { resolveLibraries } from "./resolve-libraries.js"; + +export interface DevOptions { + readonly emitter: string; + readonly port: number; + readonly libraries?: string[]; +} + +export async function runDev(options: DevOptions): Promise { + const projectRoot = process.cwd(); + const allLibraries = resolveLibraries(options.emitter, projectRoot); + + // Add any extra libraries specified via CLI + if (options.libraries) { + for (const lib of options.libraries) { + if (!allLibraries.includes(lib)) { + allLibraries.push(lib); + } + } + } + + console.log(pc.cyan("TypeSpec Playground Dev Server")); + console.log(pc.dim(`Emitter: ${options.emitter}`)); + console.log(pc.dim(`Libraries: ${allLibraries.join(", ")}`)); + console.log(); + + const viteConfig = createPlaygroundViteConfig({ + defaultEmitter: options.emitter, + libraries: allLibraries, + }); + + const server = await createServer({ + ...viteConfig, + root: projectRoot, + server: { + ...viteConfig.server, + port: options.port, + }, + }); + + await server.listen(); + server.printUrls(); +} diff --git a/packages/playground/src/cli/resolve-libraries.ts b/packages/playground/src/cli/resolve-libraries.ts new file mode 100644 index 00000000000..c0373c93847 --- /dev/null +++ b/packages/playground/src/cli/resolve-libraries.ts @@ -0,0 +1,65 @@ +import { readFileSync } from "fs"; +import { join } from "path"; + +/** + * Resolve the full set of TypeSpec libraries needed for the playground by + * walking the emitter's peerDependencies. Always includes @typespec/compiler. + */ +export function resolveLibraries(emitterName: string, projectRoot: string): string[] { + const visited = new Set(); + const result: string[] = []; + + collectDependencies(emitterName, projectRoot, visited, result); + + // Ensure @typespec/compiler is always first + if (!result.includes("@typespec/compiler")) { + result.unshift("@typespec/compiler"); + } else { + const idx = result.indexOf("@typespec/compiler"); + if (idx > 0) { + result.splice(idx, 1); + result.unshift("@typespec/compiler"); + } + } + + return result; +} + +function collectDependencies( + packageName: string, + projectRoot: string, + visited: Set, + result: string[], +): void { + if (visited.has(packageName)) return; + visited.add(packageName); + + const pkgJsonPath = resolvePackageJson(packageName, projectRoot); + if (!pkgJsonPath) return; + + const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8")); + + // Recurse into peerDependencies first (so dependencies come before dependents) + const peerDeps = pkgJson.peerDependencies ?? {}; + for (const dep of Object.keys(peerDeps)) { + if (isTypeSpecPackage(dep)) { + collectDependencies(dep, projectRoot, visited, result); + } + } + + result.push(packageName); +} + +function resolvePackageJson(packageName: string, projectRoot: string): string | undefined { + const candidate = join(projectRoot, "node_modules", packageName, "package.json"); + try { + readFileSync(candidate, "utf-8"); + return candidate; + } catch { + return undefined; + } +} + +function isTypeSpecPackage(name: string): boolean { + return name.startsWith("@typespec/"); +} diff --git a/packages/playground/src/cli/virtual-app-plugin.ts b/packages/playground/src/cli/virtual-app-plugin.ts new file mode 100644 index 00000000000..1ff3f5f4966 --- /dev/null +++ b/packages/playground/src/cli/virtual-app-plugin.ts @@ -0,0 +1,161 @@ +import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import type { Plugin, ViteDevServer } from "vite"; + +export interface PlaygroundAppPluginOptions { + readonly title?: string; +} + +const TEMP_HTML_NAME = ".typespec-playground-build.html"; + +/** + * Vite plugin that scaffolds a temporary index.html and entry.tsx so the + * playground can run without any user-created files. + */ +export function playgroundAppPlugin(options: PlaygroundAppPluginOptions = {}): Plugin { + const title = options.title ?? "TypeSpec Playground"; + let scaffoldDir: string | undefined; + let tempHtmlPath: string | undefined; + + function ensureEntryFile(root: string): string { + const dir = join(root, "node_modules", ".typespec-playground"); + mkdirSync(dir, { recursive: true }); + scaffoldDir = dir; + + const entryPath = join(dir, "entry.tsx"); + writeFileSync(entryPath, generateEntryTsx()); + return entryPath; + } + + function cleanup(): void { + if (scaffoldDir) { + try { + rmSync(scaffoldDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + scaffoldDir = undefined; + } + if (tempHtmlPath) { + try { + rmSync(tempHtmlPath, { force: true }); + } catch { + // Ignore cleanup errors + } + tempHtmlPath = undefined; + } + } + + return { + name: "typespec-playground-app", + enforce: "pre", + + config(config, { command }) { + const root = config.root ?? process.cwd(); + const entryPath = ensureEntryFile(root); + + if (command === "build") { + // Write HTML at project root level so asset paths are correct + tempHtmlPath = join(root, TEMP_HTML_NAME); + writeFileSync(tempHtmlPath, generateHtml(title, entryPath)); + + return { + build: { + rollupOptions: { + input: { + index: tempHtmlPath, + }, + }, + }, + }; + } + return undefined; + }, + + // Dev mode: serve virtual HTML via middleware + configureServer(server: ViteDevServer) { + const entryPath = join(scaffoldDir!, "entry.tsx"); + server.middlewares.use(async (req, res, next) => { + if (req.url === "/" || req.url === "/index.html") { + try { + let html = generateHtml(title, entryPath); + html = await server.transformIndexHtml(req.url, html); + res.setHeader("Content-Type", "text/html"); + res.statusCode = 200; + res.end(html); + } catch (e) { + next(e); + } + return; + } + next(); + }); + }, + + // Rename the build output HTML from the temp name to index.html + generateBundle(_options, bundle) { + if (bundle[TEMP_HTML_NAME]) { + const asset = bundle[TEMP_HTML_NAME]; + asset.fileName = "index.html"; + bundle["index.html"] = asset; + delete bundle[TEMP_HTML_NAME]; + } + }, + + writeBundle(options) { + // Fallback: rename on disk if generateBundle didn't handle it + const outputDir = options.dir; + if (outputDir) { + const src = join(outputDir, TEMP_HTML_NAME); + const dst = join(outputDir, "index.html"); + if (existsSync(src) && !existsSync(dst)) { + renameSync(src, dst); + } + } + }, + + closeBundle() { + cleanup(); + }, + }; +} + +function generateHtml(title: string, entryPath: string): string { + return ` + + + + + ${title} + + +
+ + + +`; +} + +function generateEntryTsx(): string { + return ` +import { FluentProvider, webLightTheme } from "@fluentui/react-components"; +import { registerMonacoDefaultWorkersForVite } from "@typespec/playground"; +import PlaygroundManifest from "@typespec/playground/manifest"; +import { StandalonePlayground } from "@typespec/playground/react"; +import "@typespec/playground/styles.css"; +import { createRoot } from "react-dom/client"; + +registerMonacoDefaultWorkersForVite(); + +const App = () => ( + +); + +const root = createRoot(document.getElementById("root")!); +root.render( + + + +); +`; +} diff --git a/packages/playground/test/cli/resolve-libraries.test.ts b/packages/playground/test/cli/resolve-libraries.test.ts new file mode 100644 index 00000000000..8e8023133fd --- /dev/null +++ b/packages/playground/test/cli/resolve-libraries.test.ts @@ -0,0 +1,111 @@ +import { mkdirSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveLibraries } from "../../src/cli/resolve-libraries.js"; + +describe("resolveLibraries", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `playground-test-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + mkdirSync(join(tempDir, "node_modules"), { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + function createPackage(name: string, peerDeps: Record = {}): void { + const pkgDir = join(tempDir, "node_modules", ...name.split("/")); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ + name, + version: "1.0.0", + peerDependencies: peerDeps, + }), + ); + } + + it("always includes @typespec/compiler first", () => { + createPackage("@typespec/compiler"); + createPackage("@typespec/my-emitter", { + "@typespec/compiler": "^1.0.0", + }); + + const result = resolveLibraries("@typespec/my-emitter", tempDir); + expect(result[0]).toBe("@typespec/compiler"); + expect(result).toContain("@typespec/my-emitter"); + }); + + it("discovers transitive peerDependencies", () => { + createPackage("@typespec/compiler"); + createPackage("@typespec/http", { + "@typespec/compiler": "^1.0.0", + }); + createPackage("@typespec/openapi3", { + "@typespec/compiler": "^1.0.0", + "@typespec/http": "^1.0.0", + }); + + const result = resolveLibraries("@typespec/openapi3", tempDir); + expect(result).toEqual([ + "@typespec/compiler", + "@typespec/http", + "@typespec/openapi3", + ]); + }); + + it("ignores non-TypeSpec peer dependencies", () => { + createPackage("@typespec/compiler"); + createPackage("@typespec/my-emitter", { + "@typespec/compiler": "^1.0.0", + "some-other-lib": "^2.0.0", + }); + + const result = resolveLibraries("@typespec/my-emitter", tempDir); + expect(result).not.toContain("some-other-lib"); + expect(result).toEqual(["@typespec/compiler", "@typespec/my-emitter"]); + }); + + it("handles missing packages gracefully", () => { + createPackage("@typespec/compiler"); + createPackage("@typespec/my-emitter", { + "@typespec/compiler": "^1.0.0", + "@typespec/missing-lib": "^1.0.0", + }); + + const result = resolveLibraries("@typespec/my-emitter", tempDir); + expect(result).toEqual(["@typespec/compiler", "@typespec/my-emitter"]); + }); + + it("handles circular peer dependencies without infinite loop", () => { + createPackage("@typespec/compiler"); + createPackage("@typespec/a", { + "@typespec/compiler": "^1.0.0", + "@typespec/b": "^1.0.0", + }); + createPackage("@typespec/b", { + "@typespec/compiler": "^1.0.0", + "@typespec/a": "^1.0.0", + }); + + const result = resolveLibraries("@typespec/a", tempDir); + expect(result[0]).toBe("@typespec/compiler"); + expect(result).toContain("@typespec/a"); + expect(result).toContain("@typespec/b"); + expect(result.length).toBe(3); + }); + + it("adds @typespec/compiler even if emitter has no peer deps", () => { + createPackage("@typespec/compiler"); + createPackage("@typespec/simple-emitter"); + + const result = resolveLibraries("@typespec/simple-emitter", tempDir); + expect(result[0]).toBe("@typespec/compiler"); + expect(result).toContain("@typespec/simple-emitter"); + }); +}); diff --git a/packages/playground/vite.config.ts b/packages/playground/vite.config.ts index 0d25404b079..029cc8677c0 100644 --- a/packages/playground/vite.config.ts +++ b/packages/playground/vite.config.ts @@ -19,7 +19,13 @@ const externals = [ "react/jsx-runtime", "vite", "@vitejs/plugin-react", + // Node built-ins used by CLI + "fs", "fs/promises", + "path", + "os", + "url", + "child_process", ]; export default defineConfig({ @@ -35,6 +41,7 @@ export default defineConfig({ "react/viewers/index": "src/react/viewers/index.tsx", "tooling/index": "src/tooling/index.ts", "vite/index": "src/vite/index.ts", + "cli/cli": "src/cli/cli.ts", }, cssFileName: "style", formats: ["es"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f91e817d807..94d75429bf9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1971,6 +1971,9 @@ importers: '@typespec/versioning': specifier: workspace:^ version: link:../versioning + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 6.0.1(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.2)) clsx: specifier: 'catalog:' version: 2.1.1 @@ -1983,6 +1986,9 @@ importers: monaco-editor: specifier: 'catalog:' version: 0.55.1 + picocolors: + specifier: 'catalog:' + version: 1.1.1 react: specifier: 'catalog:' version: 19.2.4 @@ -1995,6 +2001,9 @@ importers: swagger-ui-dist: specifier: 'catalog:' version: 5.32.0 + vite: + specifier: 'catalog:' + version: 8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.2) vscode-languageserver: specifier: 'catalog:' version: 9.0.1 @@ -2004,6 +2013,9 @@ importers: yaml: specifier: 'catalog:' version: 2.8.2 + yargs: + specifier: 'catalog:' + version: 18.0.0 devDependencies: '@babel/core': specifier: 'catalog:' @@ -2041,12 +2053,12 @@ importers: '@types/swagger-ui-dist': specifier: 'catalog:' version: 3.30.6 + '@types/yargs': + specifier: 'catalog:' + version: 17.0.35 '@typespec/react-components': specifier: workspace:^ version: link:../react-components - '@vitejs/plugin-react': - specifier: 'catalog:' - version: 6.0.1(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.2)) c8: specifier: 'catalog:' version: 11.0.0 @@ -2065,9 +2077,6 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 - vite: - specifier: 'catalog:' - version: 8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.2) vite-plugin-checker: specifier: 'catalog:' version: 0.12.0(eslint@10.0.3)(optionator@0.9.4)(typescript@5.9.3)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.2))