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
19 changes: 18 additions & 1 deletion lib/backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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 { createTsgoBackend, loadTsgo, resolveTsgoPackage } from './tsgo.js';
import type { TsSyntax, TypeBackend } from './types.js';

export type {
Expand Down Expand Up @@ -67,6 +67,23 @@ function declaresContentMappers(tsconfigPath: string): boolean {
}
}

/**
* Which backend `backendFor` would pick, as a cache-key component, without
* loading TypeScript: the forced kind, or tsgo (with its package and
* version) when the tsconfig declares `contentMappers` and a TypeScript 7
* package resolves.
*/
export function backendKindFor(tsconfigPath: string): string {
const forced = process.env['HVE_TS_BACKEND'];
if (forced === 'ts6') return 'ts6';
if (forced === 'tsgo' || declaresContentMappers(tsconfigPath)) {
const pkg = resolveTsgoPackage(path.dirname(tsconfigPath));
if (pkg) return `tsgo:${pkg.name}@${pkg.version}`;
if (forced === 'tsgo') return 'none';
}
return 'ts6';
}

function selectBackend(tsconfigPath: string, filename: string): TypeBackend | null {
const forced = process.env['HVE_TS_BACKEND'];
const projectRoot = path.dirname(tsconfigPath);
Expand Down
42 changes: 31 additions & 11 deletions lib/backend/tsgo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,31 +113,51 @@ export interface TsgoModules {
const CANDIDATE_PACKAGES = ['typescript', '@typescript/native', 'typescript-7', '@typescript/native-preview'];

const modulesByRoot = new Map<string, TsgoModules | null>();
const packageByRoot = new Map<string, { name: string; version: string } | null>();

/**
* Find a TypeScript 7 package installed in the project. `typescript` itself
* when it is 7.x, otherwise the aliases projects use to run 7 next to a
* library-API 5/6 (`HVE_TSGO=<package name>` overrides the search).
* Requires Node 22.12+ (`require()` of the ESM API).
*/
export function loadTsgo(projectRoot: string): TsgoModules | null {
const cached = modulesByRoot.get(projectRoot);
/**
* The TypeScript 7 package `loadTsgo` would use, without loading it: the
* cache keys include it so that installing or removing one invalidates.
*/
export function resolveTsgoPackage(projectRoot: string): { name: string; version: string } | null {
const cached = packageByRoot.get(projectRoot);
if (cached !== undefined) return cached;
const req = createRequire(path.join(projectRoot, 'package.json'));
const override = process.env['HVE_TSGO'];
const candidates = override ? [override] : CANDIDATE_PACKAGES;
let found: TsgoModules | null = null;
for (const name of candidates) {
let found: { name: string; version: string } | null = null;
for (const name of override ? [override] : CANDIDATE_PACKAGES) {
try {
const pkg = req(`${name}/package.json`) as { version?: string };
const version = pkg.version ?? '0';
const version = (req(`${name}/package.json`) as { version?: string }).version ?? '0';
if (Number.parseInt(version, 10) < 7) continue;
const sync = req(`${name}/unstable/sync`) as TsgoSyncModule;
const ast = req(`${name}/unstable/ast`) as TsgoAstModule;
found = { packageName: name, version, sync, ast };
found = { name, version };
break;
} catch {
// not installed under this name, or not requirable — try the next
// not installed under this name — try the next
}
}
packageByRoot.set(projectRoot, found);
return found;
}

export function loadTsgo(projectRoot: string): TsgoModules | null {
const cached = modulesByRoot.get(projectRoot);
if (cached !== undefined) return cached;
const pkg = resolveTsgoPackage(projectRoot);
let found: TsgoModules | null = null;
if (pkg) {
const req = createRequire(path.join(projectRoot, 'package.json'));
try {
const sync = req(`${pkg.name}/unstable/sync`) as TsgoSyncModule;
const ast = req(`${pkg.name}/unstable/ast`) as TsgoAstModule;
found = { packageName: pkg.name, version: pkg.version, sync, ast };
} catch {
// installed but not requirable (Node < 22.12)
}
}
modulesByRoot.set(projectRoot, found);
Expand Down
90 changes: 86 additions & 4 deletions lib/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,7 @@ function findPluginVersion(): string {
}
const PLUGIN_VERSION = findPluginVersion();

// SHA over the plugin's own library code (the directory this module
// lives in). The cache entries are produced by these files; if the
// SHA over the plugin's own code: the root sources and `lib/`. The cache entries are produced by these files; if the
// files change between runs, the stored entries can be wrong even
// though the source-under-validation and tsconfig haven't moved.
// Bumping the package version on release covers consumers of the
Expand All @@ -70,7 +69,9 @@ const PLUGIN_VERSION = findPluginVersion();
// Computed once at module load: it's the same for every cache call in
// a process. Costs ~50ms on first import, then cached.
function computePluginSourceSha(): string {
const start = path.dirname(fileURLToPath(import.meta.url));
// The package root (`dist/` when built, the repo when run from source):
// `blank` and `transform` live there, next to `lib/`.
const start = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const files: string[] = [];
function walk(dir: string): void {
let entries: fs.Dirent[];
Expand All @@ -82,7 +83,8 @@ function computePluginSourceSha(): string {
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
// Only `lib/` below the root: not node_modules, tests or fixtures.
if (dir !== start || entry.name === 'lib') walk(full);
} else if (entry.isFile() && /\.(?:js|ts|cjs|mjs|hbs|gjs|gts|json)$/.test(entry.name)) {
// Skip `.d.ts` and source maps — they don't drive behaviour.
if (entry.name.endsWith('.d.ts') || entry.name.endsWith('.map')) continue;
Expand Down Expand Up @@ -259,3 +261,83 @@ export function writeCache(
// ignore — cache is best-effort
}
}

// ---------------------------------------------------------------------------
// Transform-output cache: the blanked passes of a `.gts`/`.gjs` file, keyed
// by the file's content plus everything else the transform reads (the
// tsconfig, the environment switches). Same layout and lifetime rules as
// the Glint cache above, under `.../html-validate-ember/transform/`.
// ---------------------------------------------------------------------------

export interface CachedPass {
content: string;
error: string | null;
dynamicContentOffsets: number[];
attrInjections: Array<[number, Array<{ attr: string; value: string | null }>]>;
disablePerElement: Array<[number, string[]]>;
}

export interface CachedTemplate {
startOffset: number;
endOffset: number;
passes: CachedPass[];
}

interface TransformCacheEntry {
pluginVersion: string;
pluginSourceSha: string;
key: string;
templates: CachedTemplate[];
}

/** Everything the transform's output depends on besides the plugin itself. */
export function transformCacheKey(data: string, tsconfigPath: string | null, backendKind: string): string {
return sha256(
[
data,
tsconfigPath ? getTsconfigSha(tsconfigPath) : 'no-tsconfig',
backendKind,
process.env['HVE_GLINT'] ?? '',
process.env['HVE_MAX_CONDITIONAL_BRANCHES'] ?? '',
].join('\0'),
);
}

function transformEntryPath(filename: string): string {
return path.join(findCacheDir(filename), '..', 'transform', `${sha256(path.resolve(filename))}.json`);
}

export function readTransformCache(filename: string, key: string): CachedTemplate[] | null {
if (CACHE_DISABLED) return null;
let parsed: TransformCacheEntry;
try {
parsed = JSON.parse(fs.readFileSync(transformEntryPath(filename), 'utf8')) as TransformCacheEntry;
} catch {
return null;
}
if (
parsed.pluginVersion !== PLUGIN_VERSION ||
parsed.pluginSourceSha !== PLUGIN_SOURCE_SHA ||
parsed.key !== key
) {
return null;
}
return parsed.templates;
}
Comment thread
johanrd marked this conversation as resolved.

export function writeTransformCache(filename: string, key: string, templates: CachedTemplate[]): void {
if (CACHE_DISABLED) return;
const file = transformEntryPath(filename);
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
const payload: TransformCacheEntry = {
pluginVersion: PLUGIN_VERSION,
pluginSourceSha: PLUGIN_SOURCE_SHA,
key,
templates,
};
fs.writeFileSync(file, JSON.stringify(payload));
} catch {
// ignore — cache is best-effort
}
}
38 changes: 36 additions & 2 deletions test/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import path from 'node:path';
import os from 'node:os';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';

import { readCache, writeCache } from '../lib/cache.js';
import type { ExtractionResult } from '../lib/cache.js';
import { readCache, readTransformCache, transformCacheKey, writeCache, writeTransformCache } from '../lib/cache.js';
import type { CachedTemplate, ExtractionResult } from '../lib/cache.js';

// We need a writeable parent that has a `node_modules/` so cache.ts's
// `findCacheDir` walk lands somewhere predictable. Set up a fake
Expand Down Expand Up @@ -142,3 +142,37 @@ describe('cache: one entry per file path (no accumulation)', () => {
expect(fs.readdirSync(cacheDir())).toHaveLength(2);
});
});

describe('transform cache', () => {
const templates: CachedTemplate[] = [
{
startOffset: 10,
endOffset: 40,
passes: [
{
content: '<div> </div>',
error: null,
dynamicContentOffsets: [5],
attrInjections: [[3, [{ attr: 'type', value: 'button' }, { attr: 'hidden', value: null }]]],
disablePerElement: [[0, ['no-inline-style']]],
},
{ content: '<div> </div>', error: 'unclosed', dynamicContentOffsets: [], attrInjections: [], disablePerElement: [] },
],
},
];

it('round-trips the passes, including the Map- and Set-shaped arrays', () => {
const file = path.join(templatesDir, 'a.gts');
const key = transformCacheKey('<template></template>', tsconfigPath, 'ts6');
writeTransformCache(file, key, templates);
expect(readTransformCache(file, key)).toEqual(templates);
});

it('misses when the key differs', () => {
const file = path.join(templatesDir, 'a.gts');
writeTransformCache(file, transformCacheKey('v1', tsconfigPath, 'ts6'), templates);
expect(readTransformCache(file, transformCacheKey('v2', tsconfigPath, 'ts6'))).toBeNull();
expect(readTransformCache(file, transformCacheKey('v1', tsconfigPath, 'tsgo:typescript@7.0.0'))).toBeNull();
expect(readTransformCache(file, transformCacheKey('v1', tsconfigPath, 'ts6'))).toEqual(templates);
});
});
Loading
Loading