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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/bundler"
---

Skip exports that only expose a `typespec` entrypoint (e.g. `./emitter`, `./options`) when building the JS bundle. These exports have no JS module to bundle and their TypeSpec source files are already included via the sub-export compilation, so the bundler no longer fails with a "missing import or default entrypoint" error.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: internal
packages:
- "@typespec/compiler"
---

Introduce internal `TypeGraph` concept (a self-contained compilation result) and experimental support for defining emitter options as a TypeSpec file (`exports["./options"].typespec`). Emitters opt into validating user options against their exported `EmitterOptions` model via the `experimentalEmitterOptions` package flag (`definePackageFlags`).
7 changes: 7 additions & 0 deletions .chronus/changes/json-schema-tsp-options-2026-6-26-10-20-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: internal
packages:
- "@typespec/json-schema"
---

Migrate the JSON Schema emitter to define its options as a TypeSpec model (`options/main.tsp`, exported via `exports["./options"].typespec`) instead of a hand-written JSON schema. The compiler now validates user options against that model. Removes the internal `EmitterOptionsSchema` export.
7 changes: 7 additions & 0 deletions .chronus/changes/openapi3-tsp-options-2026-6-26-10-30-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: internal
packages:
- "@typespec/openapi3"
---

Migrate the OpenAPI3 emitter to define its options as a TypeSpec model (`options/main.tsp`, exported via `exports["./options"].typespec`) instead of a hand-written JSON schema. The compiler now validates user options against that model. Removes the internal `EmitterOptionsSchema` export.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: internal
packages:
- "@typespec/tspd"
---

Generate emitter options reference docs from an emitter's TypeSpec `options/main.tsp` model when no legacy JSON-schema validator is present.
31 changes: 16 additions & 15 deletions packages/bundler/src/bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,15 @@ async function createEsBuildContext(
});

const extraEntry = Object.fromEntries(
Object.entries(definition.exports).map(([key, value]) => {
return [
key.replace("./", ""),
normalizePath(resolve(libraryPath, getExportEntryPoint(value))),
];
Object.entries(definition.exports).flatMap(([key, value]) => {
const entryPoint = getExportEntryPoint(value);
// Skip exports that only expose a TypeSpec entrypoint (e.g. `./emitter`,
// `./options`). Those have no JS module to bundle and their source files are
// already included via the sub-export compilation loop above.
if (entryPoint === undefined) {
return [];
}
return [[key.replace("./", ""), normalizePath(resolve(libraryPath, entryPoint))]];
}),
);

Expand Down Expand Up @@ -316,16 +320,13 @@ async function resolveTypeSpecBundle(
};
}

function getExportEntryPoint(value: string | ExportData) {
const resolved = typeof value === "string" ? value : (value.import ?? value.default);

if (!resolved) {
throw new Error(
`Exports ${JSON.stringify(value, null, 2)} is missing import or default entrypoint`,
);
}

return resolved;
/**
* Resolve the JS entrypoint for an export entry, or `undefined` if the export only
* exposes a TypeSpec entrypoint (e.g. `{ "typespec": "./options/main.tsp" }`) and has
* no JS module to bundle.
*/
function getExportEntryPoint(value: string | ExportData): string | undefined {
return typeof value === "string" ? value : (value.import ?? value.default);
}
async function readLibraryPackageJson(path: string): Promise<PackageJson> {
const file = await readFile(join(path, "package.json"));
Expand Down
5 changes: 5 additions & 0 deletions packages/compiler/lib/emitter/main.tsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* Define an config value that should resolve to an absolute path.
* `{project-dir}`, `{cwd}`, and other can be used to define a relative path.
*/
scalar absolutePath extends string;
3 changes: 3 additions & 0 deletions packages/compiler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@
},
"./casing": {
"import": "./dist/src/casing/index.js"
},
"./emitter": {
"typespec": "./lib/emitter/main.tsp"
}
},
"browser": {
Expand Down
4 changes: 1 addition & 3 deletions packages/compiler/src/core/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,9 +537,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
const postCheckValidators: ValidatorFn[] = [];

const typespecNamespaceBinding = resolver.symbols.global.exports!.get("TypeSpec");
if (typespecNamespaceBinding) {
initializeTypeSpecIntrinsics();
}
initializeTypeSpecIntrinsics();

/**
* Tracking the template parameters used or not.
Expand Down
4 changes: 4 additions & 0 deletions packages/compiler/src/core/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,10 @@ export function createDiagnosticCollector(): DiagnosticCollector {
}
}

export function err(diagnostic: Diagnostic): DiagnosticResult<undefined> {
return [undefined, [diagnostic]];
}

/**
* Ignore the diagnostics emitted by the diagnostic accessor pattern and just return the actual result.
* @param result Accessor pattern tuple result including the actual result and the list of diagnostics.
Expand Down
82 changes: 82 additions & 0 deletions packages/compiler/src/core/emitter-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { getLocationInYamlScript } from "../yaml/diagnostics.js";
import { YamlScript } from "../yaml/types.js";
import { createDiagnosticCollector, err } from "./diagnostics.js";
import { validateEmitterOptions } from "./emitter-options/validator.js";
import { createDiagnostic } from "./messages.js";
import { Program, TypeGraph } from "./program.js";
import { createSourceFile } from "./source-file.js";
import { Diagnostic, Model, NoTarget } from "./types.js";

export function resolveEmitterOptions(
typeGraph: TypeGraph,
): [Model | undefined, readonly Diagnostic[]] {
const [root] = typeGraph.resolveTypeReference("EmitterOptions");
const diagnostics = createDiagnosticCollector();

if (root === undefined) {
return [
undefined,
[
createDiagnostic({
code: "missing-emitter-options",
target: { file: createSourceFile("", typeGraph.entrypoint), pos: 0, end: 0 },
}),
],
];
}
if (root.kind !== "Model") {
return err(
createDiagnostic({
code: "emitter-options-not-model",
target: root,
}),
);
}
return diagnostics.wrap(root);
}

/**
* Where to anchor diagnostics produced while validating emitter options.
* `script` is the parsed `tspconfig.yaml` and `basePath` the path to the
* emitter's options inside it (e.g. `["options", "@typespec/openapi3"]`).
*/
export interface EmitterOptionsConfigTarget {
readonly script: YamlScript;
readonly basePath: string[];
}

/**
* Validate user provided emitter options against the `EmitterOptions` model
* declared by an emitter and turn validation errors into diagnostics anchored in
* the `tspconfig.yaml` when available.
*/
export function validateEmitterOptionsAgainstModel(
program: Program,
options: Record<string, unknown>,
model: Model,
target: EmitterOptionsConfigTarget | typeof NoTarget,
): readonly Diagnostic[] {
const errors = validateEmitterOptions(program, options, model);
return errors.map((error): Diagnostic => {
const diagnosticTarget =
target === NoTarget
? NoTarget
: getLocationInYamlScript(target.script, [...target.basePath, ...error.target], "key");

// Re-emit the dedicated `config-path-absolute` diagnostic so options typed with the
// `absolutePath` scalar keep parity with the legacy JSON-schema `format: absolute-path`.
if (error.code === "config-path-absolute") {
return createDiagnostic({
code: "config-path-absolute",
format: { path: error.value ?? "" },
target: diagnosticTarget,
});
}

return createDiagnostic({
code: "invalid-emitter-options",
format: { message: error.message },
target: diagnosticTarget,
});
});
}
Loading
Loading