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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,20 @@ Add the Ember/Glimmer language IDs to your project's `.vscode/settings.json`:

Install `@glint/ember-tsc` in your project and the transformer extracts TypeScript type information for two patterns. Default on when installed; pass `--no-glint` (or `HVE_GLINT=0`) to opt out.

### TypeScript 7

Type information comes from one of two backends, chosen per `tsconfig.json`:

- **TypeScript 5/6 + `@glint/ember-tsc`** (default). The project's `typescript` is used as a library and Glint's transform rewrites `.gts` files in-process.
- **TypeScript 7** (`typescript/unstable/sync`). Used when the tsconfig declares `contentMappers` (see [ember-content-mapper](https://github.com/NullVoxPopuli/ember-content-mapper)) and a TypeScript 7 package resolves from the project: `typescript` itself when it is 7.x, `@typescript/native-preview`, or the aliases `@typescript/native` / `typescript-7`. The compiler runs the content mapper, so no Glint rewrite and no `typescript` library run in this process. Needs Node 22.12+.

Both produce the same results; the TypeScript 7 backend opens a project once (a few seconds for a large app) and then answers type queries in milliseconds.

- `HVE_TS_BACKEND=tsgo` forces TypeScript 7 (Glint integration is disabled with a message when it is not resolvable); `HVE_TS_BACKEND=ts6` forces the TypeScript 5/6 pipeline.
- `HVE_TSGO=<package name>` names the TypeScript 7 package when it is installed under another alias.

Projects on the TypeScript 5/6 backend that migrated to the content mapper can keep `"ember-source/types"` and `"@glint/ember-tsc/types"` out of `compilerOptions.types`: the backend adds them itself.

### 1. String-literal-union narrowing in attribute positions

```ts
Expand Down
28 changes: 28 additions & 0 deletions examples/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": false,
"noEmit": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"esModuleInterop": true,
"lib": [
"ES2022",
"DOM"
],
"types": [
"@glint/ember-tsc/types"
]
},
"contentMappers": [
{
"package": "ember-content-mapper",
"extensions": [
".gts",
".gjs"
]
}
]
}
130 changes: 130 additions & 0 deletions lib/backend/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Backend selection, one per tsconfig.
//
// HVE_TS_BACKEND=tsgo force TypeScript 7 (fails closed when unavailable)
// HVE_TS_BACKEND=ts6 force the TypeScript 5/6 + Glint pipeline
// (unset) tsgo when the tsconfig declares `contentMappers`
// and a TypeScript 7 package resolves, else ts6

import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import type * as TS from 'typescript';

import { createTs6Backend, loadTs6Deps, ts6Syntax } from './ts6.js';
import { createTsgoBackend, loadTsgo } from './tsgo.js';
import type { TsSyntax, TypeBackend } from './types.js';

export type {
CheckerLike,
OpenedFile,
PreloadProgress,
PreloadStats,
ProgramLike,
SymbolLike,
TemplateSite,
TsSyntax,
TypeBackend,
TypeLike,
VirtualRange,
} from './types.js';

const backendByTsconfig = new Map<string, TypeBackend | null>();
const warned = new Set<string>();

function warnOnce(key: string, message: string): void {
if (warned.has(key)) return;
warned.add(key);
process.stderr.write(`[html-validate-ember] ${message}\n`);
}

function isDirectory(p: string): boolean {
try {
return fs.statSync(p).isDirectory();
} catch {
return false;
}
}

export function findTsconfig(start: string): string | null {
let dir = isDirectory(start) ? start : path.dirname(start);
while (dir !== path.dirname(dir)) {
const candidate = path.join(dir, 'tsconfig.json');
if (fs.existsSync(candidate)) {
return candidate;
}
dir = path.dirname(dir);
}
return null;
}

// tsconfig.json allows comments and trailing commas, so a text match is
// used rather than JSON.parse. `extends` chains are not followed.
function declaresContentMappers(tsconfigPath: string): boolean {
try {
return /"contentMappers"\s*:/.test(fs.readFileSync(tsconfigPath, 'utf8'));
} catch {
return false;
}
}

function selectBackend(tsconfigPath: string, filename: string): TypeBackend | null {
const forced = process.env['HVE_TS_BACKEND'];
const projectRoot = path.dirname(tsconfigPath);
if (forced === 'tsgo' || (forced !== 'ts6' && declaresContentMappers(tsconfigPath))) {
const mods = loadTsgo(projectRoot);
if (mods) {
return createTsgoBackend(mods, tsconfigPath);
}
if (forced === 'tsgo') {
warnOnce(
`tsgo:${tsconfigPath}`,
`HVE_TS_BACKEND=tsgo but no TypeScript 7 package resolves from ${projectRoot} (tried typescript, @typescript/native, typescript-7, @typescript/native-preview; set HVE_TSGO=<package name>). Glint integration disabled.`,
);
return null;
}
warnOnce(
`tsgo-fallback:${tsconfigPath}`,
`${tsconfigPath} declares contentMappers but no TypeScript 7 package resolves from ${projectRoot}; using typescript 5/6 with @glint/ember-tsc instead.`,
);
}
const deps = loadTs6Deps(filename);
if (!deps) return null;
return createTs6Backend(deps, tsconfigPath);
}

/** The backend for the project `filename` belongs to; null without a tsconfig or a usable TypeScript. */
export function backendFor(filename: string): TypeBackend | null {
const tsconfigPath = findTsconfig(filename);
if (!tsconfigPath) return null;
const cached = backendByTsconfig.get(tsconfigPath);
if (cached !== undefined) return cached;
const backend = selectBackend(tsconfigPath, filename);
backendByTsconfig.set(tsconfigPath, backend);
return backend;
}

let ownSyntax: TsSyntax | null | undefined;

// The `typescript` this package itself resolves — for syntactic walks in
// projects without a tsconfig-backed backend (classic `.hbs` addons).
function ownTs6Syntax(): TsSyntax | null {
if (ownSyntax !== undefined) return ownSyntax;
try {
const ts = createRequire(import.meta.url)('typescript') as typeof TS;
ownSyntax = typeof ts.createProgram === 'function' ? ts6Syntax(ts) : null;
} catch {
ownSyntax = null;
}
return ownSyntax;
}

/** Syntax facade for parsing files reached from `filename`. */
export function syntaxFor(filename: string): TsSyntax | null {
return backendFor(filename)?.syntax ?? ownTs6Syntax();
}

/** Dispose every backend created so far — for hosts that outlive one run. */
export function closeBackends(): void {
for (const backend of backendByTsconfig.values()) backend?.dispose();
backendByTsconfig.clear();
}
Loading
Loading