From 2d8e9647fbc1aafe20106dbfa5bd49bd432b8b07 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Sat, 18 Jul 2026 11:10:46 -0400 Subject: [PATCH 1/5] refactor(compiler): introduce TemplateSource abstraction for tsp init Replace getExecutionRoot()-based template loading in `tsp init` with a first-class TemplateSource abstraction (filesystem, in-memory, and remote implementations). This decouples template resolution from the CompilerHost filesystem contract and enables offline/embedded template sources. Extracted from #11299 as an independent, self-contained refactor. --- .../init-template-source-2026-7-14-21-30-0.md | 7 ++ .../compiler/src/core/cli/actions/init.ts | 2 + packages/compiler/src/init/core-templates.ts | 16 +-- packages/compiler/src/init/init.ts | 112 ++++++++---------- packages/compiler/src/init/scaffold.ts | 16 ++- .../file-system-template-source.ts | 17 +++ .../template-source/host-template-source.ts | 46 +++++++ .../in-memory-template-source.ts | 46 +++++++ .../src/init/template-source/index.ts | 8 ++ .../template-source/remote-template-source.ts | 15 +++ .../src/init/template-source/types.ts | 21 ++++ packages/compiler/src/internals/index.ts | 3 + packages/compiler/test/cli/init.test.ts | 73 ++++++------ .../compiler/test/e2e/init-templates.e2e.ts | 3 +- .../test/init/template-source.test.ts | 81 +++++++++++++ .../src/vscode-cmd/create-tsp-project.ts | 3 +- 16 files changed, 357 insertions(+), 112 deletions(-) create mode 100644 .chronus/changes/init-template-source-2026-7-14-21-30-0.md create mode 100644 packages/compiler/src/init/template-source/file-system-template-source.ts create mode 100644 packages/compiler/src/init/template-source/host-template-source.ts create mode 100644 packages/compiler/src/init/template-source/in-memory-template-source.ts create mode 100644 packages/compiler/src/init/template-source/index.ts create mode 100644 packages/compiler/src/init/template-source/remote-template-source.ts create mode 100644 packages/compiler/src/init/template-source/types.ts create mode 100644 packages/compiler/test/init/template-source.test.ts diff --git a/.chronus/changes/init-template-source-2026-7-14-21-30-0.md b/.chronus/changes/init-template-source-2026-7-14-21-30-0.md new file mode 100644 index 00000000000..d2fe85c26a5 --- /dev/null +++ b/.chronus/changes/init-template-source-2026-7-14-21-30-0.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/compiler" +--- + +Refactor `tsp init` template loading around a `TemplateSource` abstraction (filesystem, in-memory, and remote sources) instead of resolving templates through `CompilerHost.getExecutionRoot()`. diff --git a/packages/compiler/src/core/cli/actions/init.ts b/packages/compiler/src/core/cli/actions/init.ts index 425cc9f8b71..117363f5e23 100644 --- a/packages/compiler/src/core/cli/actions/init.ts +++ b/packages/compiler/src/core/cli/actions/init.ts @@ -1,4 +1,5 @@ import { InitTemplateError, initTypeSpecProject } from "../../../init/init.js"; +import type { TemplateSource } from "../../../init/template-source/index.js"; import { resolvePath } from "../../path-utils.js"; import { Diagnostic } from "../../types.js"; import { CliCompilerHost } from "../types.js"; @@ -11,6 +12,7 @@ export interface InitArgs { "project-name"?: string; emitters?: string[]; outputDir?: string; + coreTemplateSource?: TemplateSource; } export async function initAction( diff --git a/packages/compiler/src/init/core-templates.ts b/packages/compiler/src/init/core-templates.ts index 8e9f3ab45cb..7536a6ab728 100644 --- a/packages/compiler/src/init/core-templates.ts +++ b/packages/compiler/src/init/core-templates.ts @@ -1,22 +1,16 @@ -import { CompilerPackageRoot } from "../core/node-host.js"; -import { resolvePath } from "../core/path-utils.js"; -import type { SystemHost } from "../core/types.js"; +import type { CompilerHost } from "../core/types.js"; +import { FileSystemTemplateSource } from "./template-source/index.js"; -export const templatesDir = resolvePath(CompilerPackageRoot, "templates"); export interface LoadedCoreTemplates { readonly baseUri: string; readonly templates: Record; } let typeSpecCoreTemplates: LoadedCoreTemplates | undefined; -export async function getTypeSpecCoreTemplates(host: SystemHost): Promise { +export async function getTypeSpecCoreTemplates(host: CompilerHost): Promise { if (typeSpecCoreTemplates === undefined) { - const file = await host.readFile(resolvePath(templatesDir, "scaffolding.json")); - const content = JSON.parse(file.text); - typeSpecCoreTemplates = { - baseUri: templatesDir, - templates: content, - }; + const { templates, baseUri } = await new FileSystemTemplateSource(host).loadIndex(); + typeSpecCoreTemplates = { baseUri, templates }; } return typeSpecCoreTemplates; } diff --git a/packages/compiler/src/init/init.ts b/packages/compiler/src/init/init.ts index a3d716838d7..54b619629f2 100644 --- a/packages/compiler/src/init/init.ts +++ b/packages/compiler/src/init/init.ts @@ -5,16 +5,20 @@ import * as semver from "semver"; import { CliCompilerHost } from "../core/cli/types.js"; import { parseCliArgsArgOption } from "../core/cli/utils.js"; import { createDiagnostic } from "../core/messages.js"; -import { getBaseFileName, getDirectoryPath } from "../core/path-utils.js"; -import { CompilerHost, Diagnostic, NoTarget, SourceFile } from "../core/types.js"; +import { getBaseFileName } from "../core/path-utils.js"; +import { Diagnostic, NoTarget } from "../core/types.js"; import { installTypeSpecDependencies } from "../install/install.js"; import { MANIFEST } from "../manifest.js"; -import { readUrlOrPath } from "../utils/misc.js"; -import { getTypeSpecCoreTemplates } from "./core-templates.js"; import { validateTemplateDefinitions, ValidationResult } from "./init-template-validate.js"; import { EmitterTemplate, InitTemplate, InitTemplateInput } from "./init-template.js"; import { checkbox } from "./prompts.js"; import { isFileSkipGeneration, makeScaffoldingConfig, scaffoldNewProject } from "./scaffold.js"; +import { + FileSystemTemplateSource, + RemoteTemplateSource, + type LoadedTemplateIndex, + type TemplateSource, +} from "./template-source/index.js"; export interface InitTypeSpecProjectOptions { readonly templatesUrl?: string; @@ -23,6 +27,13 @@ export interface InitTypeSpecProjectOptions { readonly args?: string[]; readonly "project-name"?: string; readonly emitters?: string[]; + + /** + * Source for the built-in ("core") templates. Defaults to a {@link FileSystemTemplateSource} + * reading the compiler's `templates/` directory. The standalone single-executable injects an + * {@link import("./template-source/index.js").InMemoryTemplateSource} here. + */ + readonly coreTemplateSource?: TemplateSource; } export async function initTypeSpecProject( @@ -55,45 +66,49 @@ export async function initTypeSpecProjectWorker( const folderName = getBaseFileName(directory); - // Download template configuration and prompt user to select a template - // No validation is done until one has been selected - const typeSpecCoreTemplates = await getTypeSpecCoreTemplates(host); - const result = - options.templatesUrl === undefined - ? (typeSpecCoreTemplates as LoadedTemplate) - : await downloadTemplates(host, options.templatesUrl, skipPrompts); + const coreSource = options.coreTemplateSource ?? new FileSystemTemplateSource(host); + const isRemote = options.templatesUrl !== undefined; + const source = isRemote ? new RemoteTemplateSource(host, options.templatesUrl!) : coreSource; + + if (isRemote) { + warning( + `Downloading or using an untrusted template may contain malicious packages that can compromise your system and data. Proceed with caution and verify the source.`, + ); + if (!skipPrompts && !(await confirm("Continue"))) { + process.exit(1); + } + } + + // No validation is done until a template has been selected. + const index = await loadTemplateIndex(source, options.templatesUrl); if (skipPrompts && !options.template) { // A template has to be defined if we're skipping prompts throw new Error( `A template must be specified when --no-prompt is used. Specify one of the following templates via --template: ${Object.keys( - result.templates, + index.templates, ) .map((t) => `"${t}"`) .join(", ")}`, ); } - const templateName = options.template ?? (await promptTemplateSelection(result.templates)); + const templateName = options.template ?? (await promptTemplateSelection(index.templates)); - if (!result.templates[templateName]) { + if (!index.templates[templateName]) { throw new Error(`Unexpected error: Cannot find template ${templateName}`); } // Validate minimum compiler version for non built-in templates - if ( - !skipPrompts && - result !== typeSpecCoreTemplates && - !(await validateTemplate(result.templates[templateName], result)) - ) { + if (!skipPrompts && isRemote && !(await validateTemplate(index.templates[templateName], index))) { return; } - const template = result.templates[templateName] as InitTemplate; + const template = index.templates[templateName] as InitTemplate; const name = await resolveProjectName(folderName, options); const emitters = await selectEmitters(template, options); const parameters = await promptCustomParameters(template, options); const scaffoldingConfig = makeScaffoldingConfig(template, { - baseUri: result.baseUri, + source, name, directory, parameters, @@ -246,49 +261,26 @@ async function confirm(message: string): Promise { }); } -export interface LoadedTemplate { - readonly baseUri: string; - readonly templates: Record; - readonly file: SourceFile; -} -async function downloadTemplates( - host: CompilerHost, - url: string, - skipCheck: boolean, -): Promise { - warning( - `Downloading or using an untrusted template may contain malicious packages that can compromise your system and data. Proceed with caution and verify the source.`, - ); - if (!skipCheck && !(await confirm("Continue"))) { - process.exit(1); - } - let file: SourceFile; +async function loadTemplateIndex( + source: TemplateSource, + url: string | undefined, +): Promise { try { - file = await readUrlOrPath(host, url); - } catch (e: any) { - throw new InitTemplateError([ - createDiagnostic({ - code: "init-template-download-failed", - target: NoTarget, - format: { url: url, message: e.message }, - }), - ]); - } - - let json: unknown; - try { - json = JSON.parse(file.text); + return await source.loadIndex(); } catch (e: any) { + if (url === undefined) { + throw e; + } + const code = + e instanceof SyntaxError ? "init-template-invalid-json" : "init-template-download-failed"; throw new InitTemplateError([ createDiagnostic({ - code: "init-template-invalid-json", + code, target: NoTarget, - format: { url: url, message: e.message }, + format: { url, message: e.message }, }), ]); } - - return { templates: json as any, baseUri: getDirectoryPath(file.path), file }; } function getTemplateName(template: InitTemplate) { @@ -331,17 +323,17 @@ function isTemplateCompatibleWithTspVersion(template: InitTemplate): boolean { ); } -async function validateTemplate(template: any, loaded: LoadedTemplate): Promise { +async function validateTemplate(template: any, index: LoadedTemplateIndex): Promise { // After selection, validate the template definition const currentCompilerVersion = MANIFEST.version; let validationResult: ValidationResult; // 1. If current version > compilerVersion, proceed with strict validation if (isTemplateCompatibleWithTspVersion(template)) { - validationResult = validateTemplateDefinitions(template, loaded.file, true); + validationResult = validateTemplateDefinitions(template, index.indexFile, true); // 1.1 If strict validation fails, try relaxed validation if (!validationResult.valid) { - validationResult = validateTemplateDefinitions(template, loaded.file, false); + validationResult = validateTemplateDefinitions(template, index.indexFile, false); } } else { // 2. if version mis-match or none specified, warn and prompt user to continue or not @@ -352,7 +344,7 @@ async function validateTemplate(template: any, loaded: LoadedTemplate): Promise< ) ) { // 2.1 If user choose to continue, proceed with relaxed validation - validationResult = validateTemplateDefinitions(template, loaded.file, false); + validationResult = validateTemplateDefinitions(template, index.indexFile, false); } else { return false; } diff --git a/packages/compiler/src/init/scaffold.ts b/packages/compiler/src/init/scaffold.ts index eb369ccb20b..d3ce68b2123 100644 --- a/packages/compiler/src/init/scaffold.ts +++ b/packages/compiler/src/init/scaffold.ts @@ -4,7 +4,6 @@ import { getDirectoryPath, joinPaths } from "../core/path-utils.js"; import type { SystemHost } from "../core/types.js"; import { fetchLatestPackageManifest } from "../package-manger/npm-registry.js"; import type { PackageJson } from "../types/package-json.js"; -import { readUrlOrPath, resolveRelativeUrlOrPath } from "../utils/misc.js"; import { createFileTemplatingContext, type FileTemplatingContext, @@ -16,6 +15,7 @@ import type { InitTemplateLibrary, InitTemplateLibrarySpec, } from "./init-template.js"; +import type { TemplateSource } from "./template-source/index.js"; export const TypeSpecConfigFilename = "tspconfig.yaml"; @@ -24,9 +24,10 @@ export interface ScaffoldingConfig { template: InitTemplate; /** - * Path where this template was loaded from. + * Source the template was loaded from. Used to read the template's files during scaffolding. + * Optional: templates that declare no `files` never need it. */ - baseUri: string; + source?: TemplateSource; /** * Directory full path where the project should be initialized. @@ -73,7 +74,6 @@ export function makeScaffoldingConfig( return { template, libraries: config.libraries ?? template.libraries?.map(normalizeLibrary) ?? [], - baseUri: config.baseUri ?? ".", name: config.name ?? "", directory: config.directory ?? "", parameters: config.parameters ?? {}, @@ -248,8 +248,12 @@ async function writeFile( context: FileTemplatingContext, file: InitTemplateFile, ) { - const baseDir = config.baseUri + "/"; - const template = await readUrlOrPath(host, resolveRelativeUrlOrPath(baseDir, file.path)); + if (config.source === undefined) { + throw new Error( + `Cannot resolve template file "${file.path}": template was loaded without a source.`, + ); + } + const template = await config.source.readFile(file.path); const content = render(template.text, context); const destinationFilePath = joinPaths(config.directory, file.destination); // create folders in case they don't exist diff --git a/packages/compiler/src/init/template-source/file-system-template-source.ts b/packages/compiler/src/init/template-source/file-system-template-source.ts new file mode 100644 index 00000000000..915ca9f1ede --- /dev/null +++ b/packages/compiler/src/init/template-source/file-system-template-source.ts @@ -0,0 +1,17 @@ +import { CompilerPackageRoot } from "../../core/node-host.js"; +import { resolvePath } from "../../core/path-utils.js"; +import type { SystemHost } from "../../core/types.js"; +import { UriTemplateSource } from "./host-template-source.js"; + +/** Default location of the compiler's built-in `tsp init` templates. */ +export const CompilerCoreTemplatesRoot = resolvePath(CompilerPackageRoot, "templates"); + +/** + * {@link TemplateSource} backed by a `templates/` directory on disk, read through the given + * {@link SystemHost}. This is the source used by a normally-installed compiler. + */ +export class FileSystemTemplateSource extends UriTemplateSource { + constructor(host: SystemHost, rootDir: string = CompilerCoreTemplatesRoot) { + super(host, rootDir); + } +} diff --git a/packages/compiler/src/init/template-source/host-template-source.ts b/packages/compiler/src/init/template-source/host-template-source.ts new file mode 100644 index 00000000000..f0b1526d4c3 --- /dev/null +++ b/packages/compiler/src/init/template-source/host-template-source.ts @@ -0,0 +1,46 @@ +import { getDirectoryPath } from "../../core/path-utils.js"; +import type { SourceFile, SystemHost } from "../../core/types.js"; +import { readUrlOrPath, resolveRelativeUrlOrPath } from "../../utils/misc.js"; +import type { LoadedTemplateIndex, TemplateSource } from "./types.js"; + +/** File name of the template index within a template source root. */ +export const SCAFFOLDING_FILENAME = "scaffolding.json"; + +/** + * A {@link TemplateSource} that reads through a {@link SystemHost}, resolving files relative to a + * base location (a directory path or a URL). + */ +export abstract class HostTemplateSource implements TemplateSource { + constructor( + protected readonly host: SystemHost, + /** Location of the index file (`scaffolding.json`) — a path or URL. */ + private readonly indexLocation: string, + /** Base against which template file paths are resolved — a directory path or URL. */ + private readonly baseUri: string, + ) {} + + async loadIndex(): Promise { + const indexFile = await readUrlOrPath(this.host, this.indexLocation); + const templates = JSON.parse(indexFile.text); + return { templates, indexFile, baseUri: this.baseUri }; + } + + async readFile(relativePath: string): Promise { + return readUrlOrPath(this.host, resolveRelativeUrlOrPath(this.baseUri + "/", relativePath)); + } +} + +/** Derive the base directory (path or URL) that a template index at `indexLocation` sits in. */ +export function baseUriOf(indexLocation: string): string { + return getDirectoryPath(indexLocation); +} + +/** + * A {@link TemplateSource} for a template root (a directory path or URL) laid out with a + * `scaffolding.json` index at its root, for both on-disk and remote directories. + */ +export class UriTemplateSource extends HostTemplateSource { + constructor(host: SystemHost, baseUri: string) { + super(host, resolveRelativeUrlOrPath(baseUri + "/", SCAFFOLDING_FILENAME), baseUri); + } +} diff --git a/packages/compiler/src/init/template-source/in-memory-template-source.ts b/packages/compiler/src/init/template-source/in-memory-template-source.ts new file mode 100644 index 00000000000..c82d15eb898 --- /dev/null +++ b/packages/compiler/src/init/template-source/in-memory-template-source.ts @@ -0,0 +1,46 @@ +import { joinPaths, normalizePath } from "../../core/path-utils.js"; +import { createSourceFile } from "../../core/source-file.js"; +import type { SourceFile } from "../../core/types.js"; +import { SCAFFOLDING_FILENAME } from "./host-template-source.js"; +import type { LoadedTemplateIndex, TemplateSource } from "./types.js"; + +/** + * {@link TemplateSource} that serves templates from an in-memory map of relative path to file + * contents. Used by the standalone single-executable, which embeds the `templates/` tree as an asset. + */ +export class InMemoryTemplateSource implements TemplateSource { + private readonly files: ReadonlyMap; + + constructor( + files: Readonly>, + private readonly baseUri = "/__typespec_templates__", + ) { + this.files = new Map(Object.entries(files).map(([key, value]) => [normalizeKey(key), value])); + } + + async loadIndex(): Promise { + const indexFile = this.readFileSync(SCAFFOLDING_FILENAME); + return { templates: JSON.parse(indexFile.text), indexFile, baseUri: this.baseUri }; + } + + async readFile(relativePath: string): Promise { + return this.readFileSync(relativePath); + } + + private readFileSync(relativePath: string): SourceFile { + const key = normalizeKey(relativePath); + const content = this.files.get(key); + if (content === undefined) { + const error: NodeJS.ErrnoException = new Error( + `ENOENT: bundled template file not found, '${relativePath}'`, + ); + error.code = "ENOENT"; + throw error; + } + return createSourceFile(content, joinPaths(this.baseUri, key)); + } +} + +function normalizeKey(path: string): string { + return normalizePath(path).replace(/^\/+/, ""); +} diff --git a/packages/compiler/src/init/template-source/index.ts b/packages/compiler/src/init/template-source/index.ts new file mode 100644 index 00000000000..ad7de1f3adc --- /dev/null +++ b/packages/compiler/src/init/template-source/index.ts @@ -0,0 +1,8 @@ +export { + CompilerCoreTemplatesRoot, + FileSystemTemplateSource, +} from "./file-system-template-source.js"; +export { UriTemplateSource } from "./host-template-source.js"; +export { InMemoryTemplateSource } from "./in-memory-template-source.js"; +export { RemoteTemplateSource } from "./remote-template-source.js"; +export type { LoadedTemplateIndex, TemplateSource } from "./types.js"; diff --git a/packages/compiler/src/init/template-source/remote-template-source.ts b/packages/compiler/src/init/template-source/remote-template-source.ts new file mode 100644 index 00000000000..d121f10acd2 --- /dev/null +++ b/packages/compiler/src/init/template-source/remote-template-source.ts @@ -0,0 +1,15 @@ +import type { SystemHost } from "../../core/types.js"; +import { baseUriOf, HostTemplateSource } from "./host-template-source.js"; + +/** + * {@link TemplateSource} for a template index hosted at a URL (`tsp init `), read + * through the given {@link SystemHost}. Template files are resolved relative to the index URL. + * + * Loading an untrusted template can execute arbitrary code during scaffolding; callers are + * responsible for confirming the source with the user before using this. + */ +export class RemoteTemplateSource extends HostTemplateSource { + constructor(host: SystemHost, url: string) { + super(host, url, baseUriOf(url)); + } +} diff --git a/packages/compiler/src/init/template-source/types.ts b/packages/compiler/src/init/template-source/types.ts new file mode 100644 index 00000000000..5782d9a46a2 --- /dev/null +++ b/packages/compiler/src/init/template-source/types.ts @@ -0,0 +1,21 @@ +import type { SourceFile } from "../../core/types.js"; +import type { InitTemplate } from "../init-template.js"; + +/** Result of loading a template index (the set of templates offered by a {@link TemplateSource}). */ +export interface LoadedTemplateIndex { + /** Templates keyed by their id. */ + readonly templates: Record; + /** Source file the index was parsed from. Used to position validation diagnostics. */ + readonly indexFile: SourceFile; + /** Location the index was loaded from (a directory path or URL). Informational only. */ + readonly baseUri: string; +} + +/** + * A source of `tsp init` templates. Decouples template loading from `host.getExecutionRoot()`: each + * context supplies its own implementation (filesystem, remote URL, or in-memory bundle). + */ +export interface TemplateSource { + loadIndex(): Promise; + readFile(relativePath: string): Promise; +} diff --git a/packages/compiler/src/internals/index.ts b/packages/compiler/src/internals/index.ts index a709fe35c0d..9c1888895b9 100644 --- a/packages/compiler/src/internals/index.ts +++ b/packages/compiler/src/internals/index.ts @@ -11,5 +11,8 @@ export { resolveCompilerOptions } from "../config/config-to-options.js"; export { NodeSystemHost } from "../core/node-system-host.js"; export { InitTemplateSchema } from "../init/init-template.js"; export { makeScaffoldingConfig, scaffoldNewProject } from "../init/scaffold.js"; +// From host-template-source directly (not the barrel) to avoid pulling node-host's top-level await. +export { UriTemplateSource } from "../init/template-source/host-template-source.js"; +export type { TemplateSource } from "../init/template-source/types.js"; export { resolveEntrypointFile } from "../server/entrypoint-resolver.js"; export { InternalCompileResult, ServerDiagnostic } from "../server/index.js"; diff --git a/packages/compiler/test/cli/init.test.ts b/packages/compiler/test/cli/init.test.ts index 064ef45d318..d4bc61c626b 100644 --- a/packages/compiler/test/cli/init.test.ts +++ b/packages/compiler/test/cli/init.test.ts @@ -1,9 +1,9 @@ import { afterAll, afterEach, beforeEach, expect, it, vi } from "vitest"; import { CliCompilerHost } from "../../src/core/cli/types.js"; -import { CompilerPackageRoot } from "../../src/core/node-host.js"; import { resolvePath } from "../../src/core/path-utils.js"; import { LogSink } from "../../src/index.js"; -import { initTypeSpecProject } from "../../src/init/init.js"; +import { initTypeSpecProject, InitTypeSpecProjectOptions } from "../../src/init/init.js"; +import { FileSystemTemplateSource } from "../../src/init/template-source/index.js"; import { createTestFileSystem } from "../../src/testing/fs.js"; import { TestFileSystem } from "../../src/testing/types.js"; import { parseYaml as coreParseYaml } from "../../src/yaml/parser.js"; @@ -88,17 +88,19 @@ const TEST_SCAFFOLDING = { }; async function createTestFSWithCliCompilerHost(): Promise< - TestFileSystem & { compilerHost: CliCompilerHost } + TestFileSystem & { + compilerHost: CliCompilerHost; + init: (directory: string, options?: InitTypeSpecProjectOptions) => Promise; + } > { const testHost = await createTestFileSystem(); - testHost.fs.set( - resolvePath(CompilerPackageRoot, "templates", "scaffolding.json"), - JSON.stringify(TEST_SCAFFOLDING), - ); + const executionRoot = testHost.compilerHost.getExecutionRoot(); + const templatesRoot = resolvePath(executionRoot, "templates"); + testHost.fs.set(resolvePath(templatesRoot, "scaffolding.json"), JSON.stringify(TEST_SCAFFOLDING)); testHost.fs.set( - resolvePath(CompilerPackageRoot, "templates", "withParams", "tspconfig.yaml"), + resolvePath(templatesRoot, "withParams", "tspconfig.yaml"), ` emit: @typespec/openapi3 options: @@ -125,7 +127,12 @@ parameters: }; Object.assign(testHost.compilerHost as CliCompilerHost, { logSink }); - return testHost as TestFileSystem & { compilerHost: CliCompilerHost }; + const compilerHost = testHost.compilerHost as CliCompilerHost; + // Serve the built-in templates from the test file system rather than the real compiler package. + const coreTemplateSource = new FileSystemTemplateSource(compilerHost, templatesRoot); + const init = (directory: string, options: InitTypeSpecProjectOptions = {}) => + initTypeSpecProject(compilerHost, directory, { ...options, coreTemplateSource }); + return Object.assign(testHost as TestFileSystem & { compilerHost: CliCompilerHost }, { init }); } function parseJson(host: CliCompilerHost, path: string): Promise> { @@ -142,9 +149,9 @@ afterAll(() => { }); it("should create a new project with the specified template", async () => { - const { compilerHost } = await createTestFSWithCliCompilerHost(); + const { compilerHost, init } = await createTestFSWithCliCompilerHost(); - await initTypeSpecProject(compilerHost, "/tmp/test-project", { + await init("/tmp/test-project", { template: "foo", "no-prompt": true, }); @@ -157,10 +164,10 @@ it("should create a new project with the specified template", async () => { }); it("does not ask for permission if directory has files", async () => { - const { fs, compilerHost } = await createTestFSWithCliCompilerHost(); + const { fs, compilerHost, init } = await createTestFSWithCliCompilerHost(); fs.set("/tmp/test-project/some-file.txt", "content"); - await initTypeSpecProject(compilerHost, "/tmp/test-project", { + await init("/tmp/test-project", { template: "foo", "no-prompt": true, }); @@ -169,9 +176,9 @@ it("does not ask for permission if directory has files", async () => { }); it("should support overriding project name", async () => { - const { compilerHost } = await createTestFSWithCliCompilerHost(); + const { compilerHost, init } = await createTestFSWithCliCompilerHost(); - await initTypeSpecProject(compilerHost, "/tmp/test-project", { + await init("/tmp/test-project", { template: "foo", "no-prompt": true, "project-name": "custom-project-name", @@ -182,9 +189,9 @@ it("should support overriding project name", async () => { }); it("should support overriding emitters", async () => { - const { compilerHost } = await createTestFSWithCliCompilerHost(); + const { compilerHost, init } = await createTestFSWithCliCompilerHost(); - await initTypeSpecProject(compilerHost, "/tmp/test-project", { + await init("/tmp/test-project", { template: "foo", "no-prompt": true, emitters: ["@typespec/openapi3", "@typespec/http-client-csharp"], @@ -195,8 +202,8 @@ it("should support overriding emitters", async () => { }); it("defaults to initialValue for parameters", async () => { - const { compilerHost } = await createTestFSWithCliCompilerHost(); - await initTypeSpecProject(compilerHost, "/tmp/test-project", { + const { compilerHost, init } = await createTestFSWithCliCompilerHost(); + await init("/tmp/test-project", { template: "withParams", "no-prompt": true, args: ["param1=value1"], @@ -211,8 +218,8 @@ it("defaults to initialValue for parameters", async () => { }); it("should support passing in arguments", async () => { - const { compilerHost } = await createTestFSWithCliCompilerHost(); - await initTypeSpecProject(compilerHost, "/tmp/test-project", { + const { compilerHost, init } = await createTestFSWithCliCompilerHost(); + await init("/tmp/test-project", { template: "withParams", "no-prompt": true, args: ["param1=value1", "param2=value2", "param3=value3"], @@ -227,9 +234,9 @@ it("should support passing in arguments", async () => { }); it("can't add emitters not specified by the template", async () => { - const { compilerHost } = await createTestFSWithCliCompilerHost(); + const { compilerHost, init } = await createTestFSWithCliCompilerHost(); - await initTypeSpecProject(compilerHost, "/tmp/test-project", { + await init("/tmp/test-project", { template: "foo", "no-prompt": true, emitters: ["@typespec/openapi3", "@typespec/http-client-csharp", "my-fake-emitter"], @@ -240,10 +247,10 @@ it("can't add emitters not specified by the template", async () => { }); it("should throw an error if no template is specified with no-prompt", async () => { - const { compilerHost } = await createTestFSWithCliCompilerHost(); + const { init } = await createTestFSWithCliCompilerHost(); await expect( - initTypeSpecProject(compilerHost, "/tmp/test-project", { + init("/tmp/test-project", { "no-prompt": true, }), ).rejects.toThrowError( @@ -252,10 +259,10 @@ it("should throw an error if no template is specified with no-prompt", async () }); it("should throw an error if the specified template does not exist", async () => { - const { compilerHost } = await createTestFSWithCliCompilerHost(); + const { init } = await createTestFSWithCliCompilerHost(); await expect( - initTypeSpecProject(compilerHost, "/tmp/test-project", { + init("/tmp/test-project", { template: "non-existent-template", "no-prompt": true, }), @@ -263,10 +270,10 @@ it("should throw an error if the specified template does not exist", async () => }); it("should throw an error if a required argument is not provided", async () => { - const { compilerHost } = await createTestFSWithCliCompilerHost(); + const { init } = await createTestFSWithCliCompilerHost(); await expect( - initTypeSpecProject(compilerHost, "/tmp/test-project", { + init("/tmp/test-project", { template: "withParams", "no-prompt": true, }), @@ -276,9 +283,9 @@ it("should throw an error if a required argument is not provided", async () => { }); it("should resolve package versions from npm registry", async () => { - const { compilerHost } = await createTestFSWithCliCompilerHost(); + const { compilerHost, init } = await createTestFSWithCliCompilerHost(); - await initTypeSpecProject(compilerHost, "/tmp/test-project", { + await init("/tmp/test-project", { template: "foo", "no-prompt": true, }); @@ -292,8 +299,8 @@ it("should resolve package versions from npm registry", async () => { it("should fallback to 'latest' when npm registry is unreachable", async () => { fetchMock.mockRejectedValue(new Error("Network error")); - const { compilerHost } = await createTestFSWithCliCompilerHost(); - await initTypeSpecProject(compilerHost, "/tmp/test-project", { + const { compilerHost, init } = await createTestFSWithCliCompilerHost(); + await init("/tmp/test-project", { template: "foo", "no-prompt": true, }); diff --git a/packages/compiler/test/e2e/init-templates.e2e.ts b/packages/compiler/test/e2e/init-templates.e2e.ts index c9400af6491..6d94c9c7c68 100644 --- a/packages/compiler/test/e2e/init-templates.e2e.ts +++ b/packages/compiler/test/e2e/init-templates.e2e.ts @@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, it, vi } from "vitest"; import { NodeHost } from "../../src/index.js"; import { getTypeSpecCoreTemplates } from "../../src/init/core-templates.js"; import { makeScaffoldingConfig, scaffoldNewProject } from "../../src/init/scaffold.js"; +import { FileSystemTemplateSource } from "../../src/init/template-source/index.js"; const fetchMock = vi.fn().mockResolvedValue({ json: () => Promise.resolve({ name: "mock-pkg", version: "1.0.0" }), @@ -122,7 +123,7 @@ describe("Init templates e2e tests", () => { makeScaffoldingConfig(template, { name, directory: targetFolder, - baseUri: typeSpecCoreTemplates.baseUri, + source: new FileSystemTemplateSource(NodeHost), }), ); } diff --git a/packages/compiler/test/init/template-source.test.ts b/packages/compiler/test/init/template-source.test.ts new file mode 100644 index 00000000000..b8d4c71fecd --- /dev/null +++ b/packages/compiler/test/init/template-source.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { resolvePath } from "../../src/core/path-utils.js"; +import type { CompilerHost } from "../../src/core/types.js"; +import { + FileSystemTemplateSource, + InMemoryTemplateSource, + RemoteTemplateSource, +} from "../../src/init/template-source/index.js"; +import { createTestFileSystem } from "../../src/testing/fs.js"; +import type { TestFileSystem } from "../../src/testing/types.js"; + +const scaffolding = { + sample: { title: "Sample", description: "A sample template", files: [] }, +}; + +describe("FileSystemTemplateSource", () => { + let testFs: TestFileSystem; + const root = resolvePath("/root/templates"); + + beforeEach(async () => { + testFs = await createTestFileSystem(); + testFs.fs.set(resolvePath(root, "scaffolding.json"), JSON.stringify(scaffolding)); + testFs.fs.set(resolvePath(root, "sample", "main.tsp"), "op ping(): void;"); + }); + + it("loads the index from scaffolding.json", async () => { + const source = new FileSystemTemplateSource(testFs.compilerHost, root); + const index = await source.loadIndex(); + expect(index.templates.sample.title).toBe("Sample"); + expect(index.baseUri).toBe(root); + }); + + it("reads template files relative to the root", async () => { + const source = new FileSystemTemplateSource(testFs.compilerHost, root); + const file = await source.readFile("sample/main.tsp"); + expect(file.text).toBe("op ping(): void;"); + }); +}); + +describe("InMemoryTemplateSource", () => { + const files = { + "scaffolding.json": JSON.stringify(scaffolding), + "sample/main.tsp": "op ping(): void;", + }; + + it("loads the index from the in-memory map", async () => { + const source = new InMemoryTemplateSource(files); + const index = await source.loadIndex(); + expect(index.templates.sample.title).toBe("Sample"); + }); + + it("reads template files from the map, ignoring leading ./", async () => { + const source = new InMemoryTemplateSource(files); + const file = await source.readFile("./sample/main.tsp"); + expect(file.text).toBe("op ping(): void;"); + }); + + it("throws ENOENT for a missing file", async () => { + const source = new InMemoryTemplateSource(files); + await expect(source.readFile("does/not/exist.tsp")).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); + +describe("RemoteTemplateSource", () => { + it("resolves template files relative to the index URL", async () => { + const reads: string[] = []; + const host = { + readUrl: async (url: string) => { + reads.push(url); + return { path: url, text: url.endsWith("index.json") ? JSON.stringify(scaffolding) : "hi" }; + }, + } as unknown as CompilerHost; + + const source = new RemoteTemplateSource(host, "https://example.com/tpl/index.json"); + const index = await source.loadIndex(); + expect(index.templates.sample.title).toBe("Sample"); + + await source.readFile("sample/main.tsp"); + expect(reads).toContain("https://example.com/tpl/sample/main.tsp"); + }); +}); diff --git a/packages/typespec-vscode/src/vscode-cmd/create-tsp-project.ts b/packages/typespec-vscode/src/vscode-cmd/create-tsp-project.ts index 5e17161f81c..8cc5a704932 100644 --- a/packages/typespec-vscode/src/vscode-cmd/create-tsp-project.ts +++ b/packages/typespec-vscode/src/vscode-cmd/create-tsp-project.ts @@ -8,6 +8,7 @@ import { makeScaffoldingConfig, NodeSystemHost, scaffoldNewProject, + UriTemplateSource, } from "@typespec/compiler/internals"; import { Ajv } from "ajv"; import * as semver from "semver"; @@ -194,7 +195,7 @@ export async function createTypeSpecProject( } const initTemplateConfig = makeScaffoldingConfig(info.template!, { - baseUri: info.baseUrl, + source: new UriTemplateSource(NodeSystemHost, info.baseUrl), name: projectName!, directory: selectedRootFolder, parameters: inputs ?? {}, From 188775f89a14868f623eecd3cbaecbf4cc7f92c0 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Sat, 18 Jul 2026 14:38:42 -0400 Subject: [PATCH 2/5] rethink --- .../init-template-source-2026-7-14-21-30-0.md | 2 +- .../compiler/src/core/cli/actions/init.ts | 2 - packages/compiler/src/init/core-templates.ts | 8 +- packages/compiler/src/init/init.ts | 22 +++--- .../template-source/default-core-templates.ts | 14 ++++ .../file-system-template-source.ts | 17 ----- .../template-source/host-template-source.ts | 46 ------------ .../in-memory-template-source.ts | 34 ++++++--- .../src/init/template-source/index.ts | 8 +- .../template-source/remote-template-source.ts | 15 ---- .../src/init/template-source/types.ts | 5 +- .../template-source/uri-template-source.ts | 54 +++++++++++++ packages/compiler/src/internals/index.ts | 4 +- packages/compiler/test/cli/init.test.ts | 6 +- .../compiler/test/e2e/init-templates.e2e.ts | 4 +- .../test/init/template-source.test.ts | 75 ++++++++++--------- .../src/vscode-cmd/create-tsp-project.ts | 2 +- 17 files changed, 162 insertions(+), 156 deletions(-) create mode 100644 packages/compiler/src/init/template-source/default-core-templates.ts delete mode 100644 packages/compiler/src/init/template-source/file-system-template-source.ts delete mode 100644 packages/compiler/src/init/template-source/host-template-source.ts delete mode 100644 packages/compiler/src/init/template-source/remote-template-source.ts create mode 100644 packages/compiler/src/init/template-source/uri-template-source.ts diff --git a/.chronus/changes/init-template-source-2026-7-14-21-30-0.md b/.chronus/changes/init-template-source-2026-7-14-21-30-0.md index d2fe85c26a5..372a355607c 100644 --- a/.chronus/changes/init-template-source-2026-7-14-21-30-0.md +++ b/.chronus/changes/init-template-source-2026-7-14-21-30-0.md @@ -4,4 +4,4 @@ packages: - "@typespec/compiler" --- -Refactor `tsp init` template loading around a `TemplateSource` abstraction (filesystem, in-memory, and remote sources) instead of resolving templates through `CompilerHost.getExecutionRoot()`. +Refactor `tsp init` template loading around a URI-based `TemplateSource` abstraction. A `UriTemplateSource` handles local and remote templates (paths and URLs), while built-in ("core") templates are addressed through an `internal:` scheme that resolves to an injectable provider. This lets alternative hosts (e.g. an offline single-executable compiler) serve bundled templates via an `InMemoryTemplateSource` without coupling template loading to the `CompilerHost` filesystem. diff --git a/packages/compiler/src/core/cli/actions/init.ts b/packages/compiler/src/core/cli/actions/init.ts index 117363f5e23..425cc9f8b71 100644 --- a/packages/compiler/src/core/cli/actions/init.ts +++ b/packages/compiler/src/core/cli/actions/init.ts @@ -1,5 +1,4 @@ import { InitTemplateError, initTypeSpecProject } from "../../../init/init.js"; -import type { TemplateSource } from "../../../init/template-source/index.js"; import { resolvePath } from "../../path-utils.js"; import { Diagnostic } from "../../types.js"; import { CliCompilerHost } from "../types.js"; @@ -12,7 +11,6 @@ export interface InitArgs { "project-name"?: string; emitters?: string[]; outputDir?: string; - coreTemplateSource?: TemplateSource; } export async function initAction( diff --git a/packages/compiler/src/init/core-templates.ts b/packages/compiler/src/init/core-templates.ts index 7536a6ab728..1aa30bc2df8 100644 --- a/packages/compiler/src/init/core-templates.ts +++ b/packages/compiler/src/init/core-templates.ts @@ -1,5 +1,5 @@ -import type { CompilerHost } from "../core/types.js"; -import { FileSystemTemplateSource } from "./template-source/index.js"; +import type { SystemHost } from "../core/types.js"; +import { defaultInternalTemplateSource } from "./template-source/index.js"; export interface LoadedCoreTemplates { readonly baseUri: string; @@ -7,9 +7,9 @@ export interface LoadedCoreTemplates { } let typeSpecCoreTemplates: LoadedCoreTemplates | undefined; -export async function getTypeSpecCoreTemplates(host: CompilerHost): Promise { +export async function getTypeSpecCoreTemplates(host: SystemHost): Promise { if (typeSpecCoreTemplates === undefined) { - const { templates, baseUri } = await new FileSystemTemplateSource(host).loadIndex(); + const { templates, baseUri } = await defaultInternalTemplateSource(host).loadIndex(); typeSpecCoreTemplates = { baseUri, templates }; } return typeSpecCoreTemplates; diff --git a/packages/compiler/src/init/init.ts b/packages/compiler/src/init/init.ts index 54b619629f2..27bc648d9fc 100644 --- a/packages/compiler/src/init/init.ts +++ b/packages/compiler/src/init/init.ts @@ -14,8 +14,8 @@ import { EmitterTemplate, InitTemplate, InitTemplateInput } from "./init-templat import { checkbox } from "./prompts.js"; import { isFileSkipGeneration, makeScaffoldingConfig, scaffoldNewProject } from "./scaffold.js"; import { - FileSystemTemplateSource, - RemoteTemplateSource, + defaultInternalTemplateSource, + UriTemplateSource, type LoadedTemplateIndex, type TemplateSource, } from "./template-source/index.js"; @@ -27,13 +27,12 @@ export interface InitTypeSpecProjectOptions { readonly args?: string[]; readonly "project-name"?: string; readonly emitters?: string[]; - /** - * Source for the built-in ("core") templates. Defaults to a {@link FileSystemTemplateSource} - * reading the compiler's `templates/` directory. The standalone single-executable injects an - * {@link import("./template-source/index.js").InMemoryTemplateSource} here. + * Provider that the `internal:` scheme resolves to (the built-in templates). Defaults to the + * compiler's on-disk `templates/` directory; the standalone single-executable injects an in-memory + * bundle instead. */ - readonly coreTemplateSource?: TemplateSource; + readonly internalTemplateSource?: TemplateSource; } export async function initTypeSpecProject( @@ -66,9 +65,14 @@ export async function initTypeSpecProjectWorker( const folderName = getBaseFileName(directory); - const coreSource = options.coreTemplateSource ?? new FileSystemTemplateSource(host); const isRemote = options.templatesUrl !== undefined; - const source = isRemote ? new RemoteTemplateSource(host, options.templatesUrl!) : coreSource; + // A `templatesUrl` points at a filesystem path or URL; otherwise use the built-in ("internal") + // templates, either an injected provider (e.g. bundled in the standalone executable) or the + // compiler's on-disk `templates/` directory. + const source: TemplateSource = + options.templatesUrl !== undefined + ? new UriTemplateSource(host, options.templatesUrl) + : (options.internalTemplateSource ?? defaultInternalTemplateSource(host)); if (isRemote) { warning( diff --git a/packages/compiler/src/init/template-source/default-core-templates.ts b/packages/compiler/src/init/template-source/default-core-templates.ts new file mode 100644 index 00000000000..3bf76b30398 --- /dev/null +++ b/packages/compiler/src/init/template-source/default-core-templates.ts @@ -0,0 +1,14 @@ +import { CompilerPackageRoot } from "../../core/node-host.js"; +import { resolvePath } from "../../core/path-utils.js"; +import type { SystemHost } from "../../core/types.js"; +import type { TemplateSource } from "./types.js"; +import { UriTemplateSource } from "./uri-template-source.js"; + +/** + * The default source of built-in ("internal") templates: the compiler package's on-disk + * `templates/` directory, read through the given {@link SystemHost}. The standalone single-executable + * overrides this by injecting its own {@link InMemoryTemplateSource} bundle. + */ +export function defaultInternalTemplateSource(host: SystemHost): TemplateSource { + return UriTemplateSource.fromDirectory(host, resolvePath(CompilerPackageRoot, "templates")); +} diff --git a/packages/compiler/src/init/template-source/file-system-template-source.ts b/packages/compiler/src/init/template-source/file-system-template-source.ts deleted file mode 100644 index 915ca9f1ede..00000000000 --- a/packages/compiler/src/init/template-source/file-system-template-source.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { CompilerPackageRoot } from "../../core/node-host.js"; -import { resolvePath } from "../../core/path-utils.js"; -import type { SystemHost } from "../../core/types.js"; -import { UriTemplateSource } from "./host-template-source.js"; - -/** Default location of the compiler's built-in `tsp init` templates. */ -export const CompilerCoreTemplatesRoot = resolvePath(CompilerPackageRoot, "templates"); - -/** - * {@link TemplateSource} backed by a `templates/` directory on disk, read through the given - * {@link SystemHost}. This is the source used by a normally-installed compiler. - */ -export class FileSystemTemplateSource extends UriTemplateSource { - constructor(host: SystemHost, rootDir: string = CompilerCoreTemplatesRoot) { - super(host, rootDir); - } -} diff --git a/packages/compiler/src/init/template-source/host-template-source.ts b/packages/compiler/src/init/template-source/host-template-source.ts deleted file mode 100644 index f0b1526d4c3..00000000000 --- a/packages/compiler/src/init/template-source/host-template-source.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { getDirectoryPath } from "../../core/path-utils.js"; -import type { SourceFile, SystemHost } from "../../core/types.js"; -import { readUrlOrPath, resolveRelativeUrlOrPath } from "../../utils/misc.js"; -import type { LoadedTemplateIndex, TemplateSource } from "./types.js"; - -/** File name of the template index within a template source root. */ -export const SCAFFOLDING_FILENAME = "scaffolding.json"; - -/** - * A {@link TemplateSource} that reads through a {@link SystemHost}, resolving files relative to a - * base location (a directory path or a URL). - */ -export abstract class HostTemplateSource implements TemplateSource { - constructor( - protected readonly host: SystemHost, - /** Location of the index file (`scaffolding.json`) — a path or URL. */ - private readonly indexLocation: string, - /** Base against which template file paths are resolved — a directory path or URL. */ - private readonly baseUri: string, - ) {} - - async loadIndex(): Promise { - const indexFile = await readUrlOrPath(this.host, this.indexLocation); - const templates = JSON.parse(indexFile.text); - return { templates, indexFile, baseUri: this.baseUri }; - } - - async readFile(relativePath: string): Promise { - return readUrlOrPath(this.host, resolveRelativeUrlOrPath(this.baseUri + "/", relativePath)); - } -} - -/** Derive the base directory (path or URL) that a template index at `indexLocation` sits in. */ -export function baseUriOf(indexLocation: string): string { - return getDirectoryPath(indexLocation); -} - -/** - * A {@link TemplateSource} for a template root (a directory path or URL) laid out with a - * `scaffolding.json` index at its root, for both on-disk and remote directories. - */ -export class UriTemplateSource extends HostTemplateSource { - constructor(host: SystemHost, baseUri: string) { - super(host, resolveRelativeUrlOrPath(baseUri + "/", SCAFFOLDING_FILENAME), baseUri); - } -} diff --git a/packages/compiler/src/init/template-source/in-memory-template-source.ts b/packages/compiler/src/init/template-source/in-memory-template-source.ts index c82d15eb898..46eed815ae3 100644 --- a/packages/compiler/src/init/template-source/in-memory-template-source.ts +++ b/packages/compiler/src/init/template-source/in-memory-template-source.ts @@ -1,33 +1,43 @@ -import { joinPaths, normalizePath } from "../../core/path-utils.js"; +import { normalizePath } from "../../core/path-utils.js"; import { createSourceFile } from "../../core/source-file.js"; import type { SourceFile } from "../../core/types.js"; -import { SCAFFOLDING_FILENAME } from "./host-template-source.js"; import type { LoadedTemplateIndex, TemplateSource } from "./types.js"; +import { SCAFFOLDING_FILENAME } from "./uri-template-source.js"; + +/** Prefix used to label the virtual files this source serves in diagnostics. */ +const INTERNAL_URI_PREFIX = "internal:"; /** - * {@link TemplateSource} that serves templates from an in-memory map of relative path to file - * contents. Used by the standalone single-executable, which embeds the `templates/` tree as an asset. + * A {@link TemplateSource} serving an index and its template files from an in-memory map keyed by + * relative path. Used for built-in templates that are bundled rather than on disk — e.g. the + * standalone single-executable injects one built from its embedded assets. Keys and lookups are + * normalized so callers can use `./`-relative or backslash paths interchangeably. */ export class InMemoryTemplateSource implements TemplateSource { private readonly files: ReadonlyMap; + /** + * @param files Template files keyed by their relative path (including the index). + * @param indexPath Relative path of the index within {@link files}. + */ constructor( - files: Readonly>, - private readonly baseUri = "/__typespec_templates__", + files: ReadonlyMap, + private readonly indexPath: string = SCAFFOLDING_FILENAME, ) { - this.files = new Map(Object.entries(files).map(([key, value]) => [normalizeKey(key), value])); + this.files = new Map([...files].map(([key, value]) => [normalizeKey(key), value])); } async loadIndex(): Promise { - const indexFile = this.readFileSync(SCAFFOLDING_FILENAME); - return { templates: JSON.parse(indexFile.text), indexFile, baseUri: this.baseUri }; + const indexFile = this.read(this.indexPath); + const templates = JSON.parse(indexFile.text); + return { templates, indexFile, baseUri: INTERNAL_URI_PREFIX }; } async readFile(relativePath: string): Promise { - return this.readFileSync(relativePath); + return this.read(relativePath); } - private readFileSync(relativePath: string): SourceFile { + private read(relativePath: string): SourceFile { const key = normalizeKey(relativePath); const content = this.files.get(key); if (content === undefined) { @@ -37,7 +47,7 @@ export class InMemoryTemplateSource implements TemplateSource { error.code = "ENOENT"; throw error; } - return createSourceFile(content, joinPaths(this.baseUri, key)); + return createSourceFile(content, `${INTERNAL_URI_PREFIX}${key}`); } } diff --git a/packages/compiler/src/init/template-source/index.ts b/packages/compiler/src/init/template-source/index.ts index ad7de1f3adc..b89038435e3 100644 --- a/packages/compiler/src/init/template-source/index.ts +++ b/packages/compiler/src/init/template-source/index.ts @@ -1,8 +1,4 @@ -export { - CompilerCoreTemplatesRoot, - FileSystemTemplateSource, -} from "./file-system-template-source.js"; -export { UriTemplateSource } from "./host-template-source.js"; +export { defaultInternalTemplateSource } from "./default-core-templates.js"; export { InMemoryTemplateSource } from "./in-memory-template-source.js"; -export { RemoteTemplateSource } from "./remote-template-source.js"; export type { LoadedTemplateIndex, TemplateSource } from "./types.js"; +export { SCAFFOLDING_FILENAME, UriTemplateSource } from "./uri-template-source.js"; diff --git a/packages/compiler/src/init/template-source/remote-template-source.ts b/packages/compiler/src/init/template-source/remote-template-source.ts deleted file mode 100644 index d121f10acd2..00000000000 --- a/packages/compiler/src/init/template-source/remote-template-source.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { SystemHost } from "../../core/types.js"; -import { baseUriOf, HostTemplateSource } from "./host-template-source.js"; - -/** - * {@link TemplateSource} for a template index hosted at a URL (`tsp init `), read - * through the given {@link SystemHost}. Template files are resolved relative to the index URL. - * - * Loading an untrusted template can execute arbitrary code during scaffolding; callers are - * responsible for confirming the source with the user before using this. - */ -export class RemoteTemplateSource extends HostTemplateSource { - constructor(host: SystemHost, url: string) { - super(host, url, baseUriOf(url)); - } -} diff --git a/packages/compiler/src/init/template-source/types.ts b/packages/compiler/src/init/template-source/types.ts index 5782d9a46a2..c8154dfe2fd 100644 --- a/packages/compiler/src/init/template-source/types.ts +++ b/packages/compiler/src/init/template-source/types.ts @@ -12,8 +12,9 @@ export interface LoadedTemplateIndex { } /** - * A source of `tsp init` templates. Decouples template loading from `host.getExecutionRoot()`: each - * context supplies its own implementation (filesystem, remote URL, or in-memory bundle). + * A source of `tsp init` templates. Decouples template loading from the host filesystem: each context + * supplies its own implementation — {@link UriTemplateSource} for a path or URL, or + * {@link InMemoryTemplateSource} for a bundle addressed through the `internal:` scheme. */ export interface TemplateSource { loadIndex(): Promise; diff --git a/packages/compiler/src/init/template-source/uri-template-source.ts b/packages/compiler/src/init/template-source/uri-template-source.ts new file mode 100644 index 00000000000..92654d9f6d3 --- /dev/null +++ b/packages/compiler/src/init/template-source/uri-template-source.ts @@ -0,0 +1,54 @@ +import { getDirectoryPath } from "../../core/path-utils.js"; +import type { SourceFile, SystemHost } from "../../core/types.js"; +import { readUrlOrPath, resolveRelativeUrlOrPath } from "../../utils/misc.js"; +import type { LoadedTemplateIndex, TemplateSource } from "./types.js"; + +/** File name of the template index within a template source directory. */ +export const SCAFFOLDING_FILENAME = "scaffolding.json"; + +/** + * A {@link TemplateSource} backed by a location: a filesystem path or a URL. The index and every + * template file are read through a {@link SystemHost} via {@link readUrlOrPath}, so this handles both + * a local directory and a remote `tsp init ` uniformly. Built-in templates are served through + * {@link defaultInternalTemplateSource} (or an injected provider) instead. + * + * Loading an untrusted remote template can execute arbitrary code during scaffolding; callers are + * responsible for confirming the source with the user before using it. + */ +export class UriTemplateSource implements TemplateSource { + /** Directory (path or URL) that template file paths are resolved against. */ + private readonly baseUri: string; + + /** + * @param host Host used to read the index and template files. + * @param indexLocation Location of the index file (a path or URL). Template files are resolved + * relative to its containing directory. + */ + constructor( + private readonly host: SystemHost, + private readonly indexLocation: string, + ) { + this.baseUri = getDirectoryPath(indexLocation); + } + + /** + * Create a source for a template root directory (path or URL) whose index is a + * `scaffolding.json` at its root. + */ + static fromDirectory(host: SystemHost, directory: string): UriTemplateSource { + return new UriTemplateSource( + host, + resolveRelativeUrlOrPath(directory + "/", SCAFFOLDING_FILENAME), + ); + } + + async loadIndex(): Promise { + const indexFile = await readUrlOrPath(this.host, this.indexLocation); + const templates = JSON.parse(indexFile.text); + return { templates, indexFile, baseUri: this.baseUri }; + } + + async readFile(relativePath: string): Promise { + return readUrlOrPath(this.host, resolveRelativeUrlOrPath(this.baseUri + "/", relativePath)); + } +} diff --git a/packages/compiler/src/internals/index.ts b/packages/compiler/src/internals/index.ts index 9c1888895b9..a393d3b44a9 100644 --- a/packages/compiler/src/internals/index.ts +++ b/packages/compiler/src/internals/index.ts @@ -11,8 +11,8 @@ export { resolveCompilerOptions } from "../config/config-to-options.js"; export { NodeSystemHost } from "../core/node-system-host.js"; export { InitTemplateSchema } from "../init/init-template.js"; export { makeScaffoldingConfig, scaffoldNewProject } from "../init/scaffold.js"; -// From host-template-source directly (not the barrel) to avoid pulling node-host's top-level await. -export { UriTemplateSource } from "../init/template-source/host-template-source.js"; +// From uri-template-source directly (not the barrel) to avoid pulling node-host's top-level await. +export { UriTemplateSource } from "../init/template-source/uri-template-source.js"; export type { TemplateSource } from "../init/template-source/types.js"; export { resolveEntrypointFile } from "../server/entrypoint-resolver.js"; export { InternalCompileResult, ServerDiagnostic } from "../server/index.js"; diff --git a/packages/compiler/test/cli/init.test.ts b/packages/compiler/test/cli/init.test.ts index d4bc61c626b..e12e98d1ae6 100644 --- a/packages/compiler/test/cli/init.test.ts +++ b/packages/compiler/test/cli/init.test.ts @@ -3,7 +3,7 @@ import { CliCompilerHost } from "../../src/core/cli/types.js"; import { resolvePath } from "../../src/core/path-utils.js"; import { LogSink } from "../../src/index.js"; import { initTypeSpecProject, InitTypeSpecProjectOptions } from "../../src/init/init.js"; -import { FileSystemTemplateSource } from "../../src/init/template-source/index.js"; +import { UriTemplateSource } from "../../src/init/template-source/index.js"; import { createTestFileSystem } from "../../src/testing/fs.js"; import { TestFileSystem } from "../../src/testing/types.js"; import { parseYaml as coreParseYaml } from "../../src/yaml/parser.js"; @@ -129,9 +129,9 @@ parameters: Object.assign(testHost.compilerHost as CliCompilerHost, { logSink }); const compilerHost = testHost.compilerHost as CliCompilerHost; // Serve the built-in templates from the test file system rather than the real compiler package. - const coreTemplateSource = new FileSystemTemplateSource(compilerHost, templatesRoot); + const internalTemplateSource = UriTemplateSource.fromDirectory(compilerHost, templatesRoot); const init = (directory: string, options: InitTypeSpecProjectOptions = {}) => - initTypeSpecProject(compilerHost, directory, { ...options, coreTemplateSource }); + initTypeSpecProject(compilerHost, directory, { ...options, internalTemplateSource }); return Object.assign(testHost as TestFileSystem & { compilerHost: CliCompilerHost }, { init }); } diff --git a/packages/compiler/test/e2e/init-templates.e2e.ts b/packages/compiler/test/e2e/init-templates.e2e.ts index 6d94c9c7c68..bc663760bb4 100644 --- a/packages/compiler/test/e2e/init-templates.e2e.ts +++ b/packages/compiler/test/e2e/init-templates.e2e.ts @@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, it, vi } from "vitest"; import { NodeHost } from "../../src/index.js"; import { getTypeSpecCoreTemplates } from "../../src/init/core-templates.js"; import { makeScaffoldingConfig, scaffoldNewProject } from "../../src/init/scaffold.js"; -import { FileSystemTemplateSource } from "../../src/init/template-source/index.js"; +import { defaultInternalTemplateSource } from "../../src/init/template-source/index.js"; const fetchMock = vi.fn().mockResolvedValue({ json: () => Promise.resolve({ name: "mock-pkg", version: "1.0.0" }), @@ -123,7 +123,7 @@ describe("Init templates e2e tests", () => { makeScaffoldingConfig(template, { name, directory: targetFolder, - source: new FileSystemTemplateSource(NodeHost), + source: defaultInternalTemplateSource(NodeHost), }), ); } diff --git a/packages/compiler/test/init/template-source.test.ts b/packages/compiler/test/init/template-source.test.ts index b8d4c71fecd..60648cf5e45 100644 --- a/packages/compiler/test/init/template-source.test.ts +++ b/packages/compiler/test/init/template-source.test.ts @@ -1,11 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { resolvePath } from "../../src/core/path-utils.js"; import type { CompilerHost } from "../../src/core/types.js"; -import { - FileSystemTemplateSource, - InMemoryTemplateSource, - RemoteTemplateSource, -} from "../../src/init/template-source/index.js"; +import { InMemoryTemplateSource, UriTemplateSource } from "../../src/init/template-source/index.js"; import { createTestFileSystem } from "../../src/testing/fs.js"; import type { TestFileSystem } from "../../src/testing/types.js"; @@ -13,7 +9,7 @@ const scaffolding = { sample: { title: "Sample", description: "A sample template", files: [] }, }; -describe("FileSystemTemplateSource", () => { +describe("UriTemplateSource", () => { let testFs: TestFileSystem; const root = resolvePath("/root/templates"); @@ -23,46 +19,33 @@ describe("FileSystemTemplateSource", () => { testFs.fs.set(resolvePath(root, "sample", "main.tsp"), "op ping(): void;"); }); - it("loads the index from scaffolding.json", async () => { - const source = new FileSystemTemplateSource(testFs.compilerHost, root); + it("loads the index from the given index location", async () => { + const source = new UriTemplateSource( + testFs.compilerHost, + resolvePath(root, "scaffolding.json"), + ); const index = await source.loadIndex(); expect(index.templates.sample.title).toBe("Sample"); expect(index.baseUri).toBe(root); }); - it("reads template files relative to the root", async () => { - const source = new FileSystemTemplateSource(testFs.compilerHost, root); + it("reads template files relative to the index directory", async () => { + const source = new UriTemplateSource( + testFs.compilerHost, + resolvePath(root, "scaffolding.json"), + ); const file = await source.readFile("sample/main.tsp"); expect(file.text).toBe("op ping(): void;"); }); -}); - -describe("InMemoryTemplateSource", () => { - const files = { - "scaffolding.json": JSON.stringify(scaffolding), - "sample/main.tsp": "op ping(): void;", - }; - it("loads the index from the in-memory map", async () => { - const source = new InMemoryTemplateSource(files); + it("fromDirectory resolves scaffolding.json at the directory root", async () => { + const source = UriTemplateSource.fromDirectory(testFs.compilerHost, root); const index = await source.loadIndex(); expect(index.templates.sample.title).toBe("Sample"); + expect((await source.readFile("sample/main.tsp")).text).toBe("op ping(): void;"); }); - it("reads template files from the map, ignoring leading ./", async () => { - const source = new InMemoryTemplateSource(files); - const file = await source.readFile("./sample/main.tsp"); - expect(file.text).toBe("op ping(): void;"); - }); - - it("throws ENOENT for a missing file", async () => { - const source = new InMemoryTemplateSource(files); - await expect(source.readFile("does/not/exist.tsp")).rejects.toMatchObject({ code: "ENOENT" }); - }); -}); - -describe("RemoteTemplateSource", () => { - it("resolves template files relative to the index URL", async () => { + it("resolves remote template files relative to the index URL", async () => { const reads: string[] = []; const host = { readUrl: async (url: string) => { @@ -71,7 +54,7 @@ describe("RemoteTemplateSource", () => { }, } as unknown as CompilerHost; - const source = new RemoteTemplateSource(host, "https://example.com/tpl/index.json"); + const source = new UriTemplateSource(host, "https://example.com/tpl/index.json"); const index = await source.loadIndex(); expect(index.templates.sample.title).toBe("Sample"); @@ -79,3 +62,27 @@ describe("RemoteTemplateSource", () => { expect(reads).toContain("https://example.com/tpl/sample/main.tsp"); }); }); + +describe("InMemoryTemplateSource", () => { + const files = new Map([ + ["scaffolding.json", JSON.stringify(scaffolding)], + ["sample/main.tsp", "op ping(): void;"], + ]); + + it("loads the index from the in-memory bundle", async () => { + const source = new InMemoryTemplateSource(files); + const index = await source.loadIndex(); + expect(index.templates.sample.title).toBe("Sample"); + expect(index.baseUri).toBe("internal:"); + }); + + it("reads template files from the in-memory bundle", async () => { + const source = new InMemoryTemplateSource(files); + expect((await source.readFile("sample/main.tsp")).text).toBe("op ping(): void;"); + }); + + it("throws a helpful error for a missing file", async () => { + const source = new InMemoryTemplateSource(files); + await expect(source.readFile("missing.tsp")).rejects.toThrow(/missing\.tsp/); + }); +}); diff --git a/packages/typespec-vscode/src/vscode-cmd/create-tsp-project.ts b/packages/typespec-vscode/src/vscode-cmd/create-tsp-project.ts index 8cc5a704932..d60818c560d 100644 --- a/packages/typespec-vscode/src/vscode-cmd/create-tsp-project.ts +++ b/packages/typespec-vscode/src/vscode-cmd/create-tsp-project.ts @@ -195,7 +195,7 @@ export async function createTypeSpecProject( } const initTemplateConfig = makeScaffoldingConfig(info.template!, { - source: new UriTemplateSource(NodeSystemHost, info.baseUrl), + source: UriTemplateSource.fromDirectory(NodeSystemHost, info.baseUrl), name: projectName!, directory: selectedRootFolder, parameters: inputs ?? {}, From 176357d8ec114095bd149dca983e85ed29233ff8 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Sat, 18 Jul 2026 14:44:30 -0400 Subject: [PATCH 3/5] refactor(compiler): use JS #private fields in template sources --- .../in-memory-template-source.ts | 19 +++++++++---------- .../template-source/uri-template-source.ts | 19 ++++++++++--------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/compiler/src/init/template-source/in-memory-template-source.ts b/packages/compiler/src/init/template-source/in-memory-template-source.ts index 46eed815ae3..0867cbec15b 100644 --- a/packages/compiler/src/init/template-source/in-memory-template-source.ts +++ b/packages/compiler/src/init/template-source/in-memory-template-source.ts @@ -14,32 +14,31 @@ const INTERNAL_URI_PREFIX = "internal:"; * normalized so callers can use `./`-relative or backslash paths interchangeably. */ export class InMemoryTemplateSource implements TemplateSource { - private readonly files: ReadonlyMap; + #files: ReadonlyMap; + #indexPath: string; /** * @param files Template files keyed by their relative path (including the index). * @param indexPath Relative path of the index within {@link files}. */ - constructor( - files: ReadonlyMap, - private readonly indexPath: string = SCAFFOLDING_FILENAME, - ) { - this.files = new Map([...files].map(([key, value]) => [normalizeKey(key), value])); + constructor(files: ReadonlyMap, indexPath: string = SCAFFOLDING_FILENAME) { + this.#indexPath = indexPath; + this.#files = new Map([...files].map(([key, value]) => [normalizeKey(key), value])); } async loadIndex(): Promise { - const indexFile = this.read(this.indexPath); + const indexFile = this.#read(this.#indexPath); const templates = JSON.parse(indexFile.text); return { templates, indexFile, baseUri: INTERNAL_URI_PREFIX }; } async readFile(relativePath: string): Promise { - return this.read(relativePath); + return this.#read(relativePath); } - private read(relativePath: string): SourceFile { + #read(relativePath: string): SourceFile { const key = normalizeKey(relativePath); - const content = this.files.get(key); + const content = this.#files.get(key); if (content === undefined) { const error: NodeJS.ErrnoException = new Error( `ENOENT: bundled template file not found, '${relativePath}'`, diff --git a/packages/compiler/src/init/template-source/uri-template-source.ts b/packages/compiler/src/init/template-source/uri-template-source.ts index 92654d9f6d3..7b42275d07d 100644 --- a/packages/compiler/src/init/template-source/uri-template-source.ts +++ b/packages/compiler/src/init/template-source/uri-template-source.ts @@ -16,19 +16,20 @@ export const SCAFFOLDING_FILENAME = "scaffolding.json"; * responsible for confirming the source with the user before using it. */ export class UriTemplateSource implements TemplateSource { + #host: SystemHost; + #indexLocation: string; /** Directory (path or URL) that template file paths are resolved against. */ - private readonly baseUri: string; + #baseUri: string; /** * @param host Host used to read the index and template files. * @param indexLocation Location of the index file (a path or URL). Template files are resolved * relative to its containing directory. */ - constructor( - private readonly host: SystemHost, - private readonly indexLocation: string, - ) { - this.baseUri = getDirectoryPath(indexLocation); + constructor(host: SystemHost, indexLocation: string) { + this.#host = host; + this.#indexLocation = indexLocation; + this.#baseUri = getDirectoryPath(indexLocation); } /** @@ -43,12 +44,12 @@ export class UriTemplateSource implements TemplateSource { } async loadIndex(): Promise { - const indexFile = await readUrlOrPath(this.host, this.indexLocation); + const indexFile = await readUrlOrPath(this.#host, this.#indexLocation); const templates = JSON.parse(indexFile.text); - return { templates, indexFile, baseUri: this.baseUri }; + return { templates, indexFile, baseUri: this.#baseUri }; } async readFile(relativePath: string): Promise { - return readUrlOrPath(this.host, resolveRelativeUrlOrPath(this.baseUri + "/", relativePath)); + return readUrlOrPath(this.#host, resolveRelativeUrlOrPath(this.#baseUri + "/", relativePath)); } } From 0249d628764b2a6b55dd18bfcc3f62e64f7807e8 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Sat, 18 Jul 2026 15:00:18 -0400 Subject: [PATCH 4/5] refactor(typespec-vscode): reuse UriTemplateSource.loadIndex for tsp init templates --- ...-source-loader-vscode-2026-7-18-14-55-0.md | 7 ++ .../src/vscode-cmd/create-tsp-project.ts | 73 ++++++------------- 2 files changed, 29 insertions(+), 51 deletions(-) create mode 100644 .chronus/changes/reuse-template-source-loader-vscode-2026-7-18-14-55-0.md diff --git a/.chronus/changes/reuse-template-source-loader-vscode-2026-7-18-14-55-0.md b/.chronus/changes/reuse-template-source-loader-vscode-2026-7-18-14-55-0.md new file mode 100644 index 00000000000..1ebe1e40453 --- /dev/null +++ b/.chronus/changes/reuse-template-source-loader-vscode-2026-7-18-14-55-0.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "typespec-vscode" +--- + +Reuse the compiler's `UriTemplateSource.loadIndex()` to load `tsp init` template indexes instead of the extension's own file/URL reading and JSON parsing, unifying how core and configured templates are loaded. diff --git a/packages/typespec-vscode/src/vscode-cmd/create-tsp-project.ts b/packages/typespec-vscode/src/vscode-cmd/create-tsp-project.ts index d60818c560d..289b6207f39 100644 --- a/packages/typespec-vscode/src/vscode-cmd/create-tsp-project.ts +++ b/packages/typespec-vscode/src/vscode-cmd/create-tsp-project.ts @@ -17,13 +17,7 @@ import vscode, { ExtensionContext, QuickPickItem } from "vscode"; import pkgJson from "../../package.json" with { type: "json" }; import { ExtensionStateManager } from "../extension-state-manager.js"; import logger from "../log/logger.js"; -import { - getBaseFileName, - getDirectoryPath, - joinPaths, - normalizePath, - resolvePath, -} from "../path-utils.js"; +import { getBaseFileName, joinPaths, normalizePath } from "../path-utils.js"; import telemetryClient from "../telemetry/telemetry-client.js"; import { TelemetryEventName } from "../telemetry/telemetry-event.js"; import { Result, ResultCode, SettingName } from "../types.js"; @@ -42,9 +36,6 @@ import { isFile, isWhitespaceStringOrUndefined, spawnExecutionAndLogToOutput, - tryParseJson, - tryReadFile, - tryReadFileOrUrl, } from "../utils.js"; export type InitTemplatesUrlSetting = { @@ -631,19 +622,12 @@ async function selectTemplate( return selected?.info; } -async function getTypeSpecCoreTemplates( - context: ExtensionContext, -): Promise<{ readonly baseUri: string; readonly templates: Record } | undefined> { +async function getTypeSpecCoreTemplates(context: ExtensionContext) { const templatesDir = context.asAbsolutePath("templates"); - const file = await tryReadFile(resolvePath(templatesDir, "scaffolding.json")); - if (file) { - const content = tryParseJson(file); - return { - baseUri: templatesDir, - templates: content, - }; - } else { - logger.error(`Failed to read core typespec templates from extension: ${templatesDir}`); + try { + return await UriTemplateSource.fromDirectory(NodeSystemHost, templatesDir).loadIndex(); + } catch (e) { + logger.error(`Failed to read core typespec templates from extension: ${templatesDir}`, [e]); return undefined; } } @@ -694,39 +678,26 @@ async function loadInitTemplates( logger.info("Loading configured and registed init templates..."); const loadFromConfig = async () => { for (const item of all) { - const { content, url } = (await tryReadFileOrUrl(item.url)) ?? { - content: undefined, - url: item.url, - }; - if (!content) { - logger.warning(`Failed to read template from ${item.url}. The url will be skipped`, [], { + let index; + try { + index = await new UriTemplateSource(NodeSystemHost, item.url).loadIndex(); + } catch (e) { + logger.warning(`Failed to load templates from ${item.url}. The url will be skipped`, [], { showOutput: false, showPopup: true, }); continue; - } else { - const json = tryParseJson(content); - if (!json) { - logger.warning( - `Failed to parse templates content from ${item.url}. The url will be skipped`, - [], - { showOutput: false, showPopup: true }, - ); - continue; - } else { - for (const [key, value] of Object.entries(json)) { - if (value !== undefined) { - const info: InitTemplateInfo = { - source: item.name, - sourceType: item.source, - baseUrl: getDirectoryPath(url), - name: key, - template: value as InitProjectTemplate, - }; - templateInfoMap.get(item.name)?.push(info) ?? - templateInfoMap.set(item.name, [info]); - } - } + } + for (const [key, value] of Object.entries(index.templates)) { + if (value !== undefined) { + const info: InitTemplateInfo = { + source: item.name, + sourceType: item.source, + baseUrl: index.baseUri, + name: key, + template: value as InitProjectTemplate, + }; + templateInfoMap.get(item.name)?.push(info) ?? templateInfoMap.set(item.name, [info]); } } } From a828ad8fc2b59f3b10abc135643c4977d8339c29 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 20 Jul 2026 08:21:56 -0400 Subject: [PATCH 5/5] format --- packages/compiler/src/internals/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compiler/src/internals/index.ts b/packages/compiler/src/internals/index.ts index a393d3b44a9..a94360e237b 100644 --- a/packages/compiler/src/internals/index.ts +++ b/packages/compiler/src/internals/index.ts @@ -12,7 +12,7 @@ export { NodeSystemHost } from "../core/node-system-host.js"; export { InitTemplateSchema } from "../init/init-template.js"; export { makeScaffoldingConfig, scaffoldNewProject } from "../init/scaffold.js"; // From uri-template-source directly (not the barrel) to avoid pulling node-host's top-level await. -export { UriTemplateSource } from "../init/template-source/uri-template-source.js"; export type { TemplateSource } from "../init/template-source/types.js"; +export { UriTemplateSource } from "../init/template-source/uri-template-source.js"; export { resolveEntrypointFile } from "../server/entrypoint-resolver.js"; export { InternalCompileResult, ServerDiagnostic } from "../server/index.js";