Skip to content

Commit d0f3e76

Browse files
committed
feat(vite): support plain javascript
[skip ci]
1 parent 9a7ae4e commit d0f3e76

23 files changed

Lines changed: 402 additions & 59 deletions

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"css-tree": "^3.1.0",
6969
"css-what": "^7.0.0",
7070
"emoji-regex": "^10.2.1",
71+
"semver": "^7.0.0",
7172
"source-map": "0.7.6",
7273
"source-map-js": "^1.2.1",
7374
"tslib": "^2.0.0"

packages/vite/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,14 @@ import { solidConfig } from '@nativescript/vite/solid';
152152
import { vueConfig } from '@nativescript/vite/vue';
153153
```
154154

155+
Plain JavaScript apps use the JavaScript helper and do not need `typescript` installed:
156+
157+
```ts
158+
import { javascriptConfig } from '@nativescript/vite/javascript';
159+
```
160+
161+
The TypeScript compiler is loaded only when a source needs the `@NativeClass` ES5 downlevel or when build-time type checking runs. If a plugin ships `@NativeClass`-decorated code and `typescript` is missing, the build logs a warning asking you to add `typescript` as a devDependency. Path aliases for JavaScript apps are read from `jsconfig.json` when there is no `tsconfig.json`.
162+
155163
### Flavors from other packages
156164

157165
A framework can ship its own flavor — config helper, server strategy and device-side

packages/vite/configuration/base.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { createRequire } from 'node:module';
55
import { pathToFileURL } from 'node:url';
66
import replace from '@rollup/plugin-replace';
77
import { viteStaticCopy } from 'vite-plugin-static-copy';
8-
import ts from 'typescript';
8+
// Plain JavaScript apps load this file without `typescript` installed: never import
9+
// it here or in anything this file pulls in; go through helpers/typescript.ts.
910
import { getCliFlags } from '../helpers/cli-flags.js';
1011
import NativeScriptPlugin from '../helpers/resolver.js';
1112
import nsConfigAsJsonPlugin from '../helpers/config-as-json.js';

packages/vite/helpers/nativeclass-transform.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import ts from 'typescript';
1+
import type * as TS from 'typescript';
2+
import { loadTypeScript, warnNativeClassSkipped } from './typescript.js';
23
// This is the active NativeClass transform: a localized textual + AST-assisted
34
// downlevel that avoids edge corruption of computed property names (e.g.
45
// ['frame-in']). It is the single production implementation in this package.
@@ -53,12 +54,19 @@ export function transformNativeClassSource(code: string, fileName: string) {
5354
// If this is JS and we see a __decorate* call that references NativeClass, strip it safely.
5455
const isJS = /\.(js|mjs|cjs)$/.test(fileName);
5556
if (isJS && /__decorate[a-zA-Z$]*\s*\(/.test(code) && /\bNativeClass\b/.test(code)) {
57+
const ts = loadTypeScript();
58+
if (!ts) {
59+
// Note: can remove when https://github.com/NativeScript/ios/pull/403 lands.
60+
// Might be worth a log or version detection on runtime version to ensure supported "nativeclass" runtime handling (without transformers).
61+
warnNativeClassSkipped(fileName);
62+
return null;
63+
}
5664
try {
5765
const sfJS = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
5866
let mutated = false;
59-
const transformer: ts.TransformerFactory<ts.SourceFile> = (ctx) => {
67+
const transformer: TS.TransformerFactory<TS.SourceFile> = (ctx) => {
6068
const factory = ctx.factory ?? ts.factory;
61-
const visit: ts.Visitor = (node) => {
69+
const visit: TS.Visitor = (node) => {
6270
if (ts.isCallExpression(node)) {
6371
const callee = node.expression;
6472
const calleeName = ts.isIdentifier(callee) ? callee.text : ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) ? `${callee.expression.text}.${callee.name.text}` : undefined;
@@ -69,7 +77,7 @@ export function transformNativeClassSource(code: string, fileName: string) {
6977
if (kept.length !== firstArg.elements.length) {
7078
mutated = true;
7179
if (kept.length === 0 && node.arguments.length >= 2) {
72-
return ts.visitNode(node.arguments[1], visit) as ts.Expression;
80+
return ts.visitNode(node.arguments[1], visit) as TS.Expression;
7381
}
7482
const newArr = factory.updateArrayLiteralExpression(firstArg, kept as any);
7583
return factory.updateCallExpression(node, node.expression, node.typeArguments, [newArr, ...node.arguments.slice(1)]);
@@ -79,9 +87,9 @@ export function transformNativeClassSource(code: string, fileName: string) {
7987
}
8088
return ts.visitEachChild(node, visit, ctx);
8189
};
82-
return (node) => ts.visitNode(node, visit) as ts.SourceFile;
90+
return (node) => ts.visitNode(node, visit) as TS.SourceFile;
8391
};
84-
const res = ts.transform<ts.SourceFile>(sfJS, [transformer]);
92+
const res = ts.transform<TS.SourceFile>(sfJS, [transformer]);
8593
const transformed = res.transformed[0];
8694
if (!mutated) {
8795
res.dispose();
@@ -110,11 +118,17 @@ export function transformNativeClassSource(code: string, fileName: string) {
110118

111119
// If neither original nor marker is present, skip transform early.
112120
if (!working.includes('@NativeClass') && !working.includes('/*__NativeClass__*/')) return null;
121+
const ts = loadTypeScript();
122+
if (!ts) {
123+
// Note: can remove when https://github.com/NativeScript/ios/pull/403 lands.
124+
warnNativeClassSkipped(fileName);
125+
return null;
126+
}
113127
try {
114128
const sf = ts.createSourceFile(fileName, working, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
115129
const edits: { start: number; end: number; text: string }[] = [];
116130
// Collect all class declarations (top-level or nested) for potential transform
117-
const collect = (node: ts.Node) => {
131+
const collect = (node: TS.Node) => {
118132
if (ts.isClassDeclaration(node)) {
119133
const fullStart = (node as any).getFullStart ? (node as any).getFullStart() : node.pos;
120134
const preamble = working.slice(fullStart, Math.min(node.getStart(sf) + 64, node.end));
@@ -136,7 +150,7 @@ export function transformNativeClassSource(code: string, fileName: string) {
136150
.outputText.replace(/enumerable:\s*false/g, 'enumerable: true');
137151
let cleaned = down.replace(/export \{\};?\s*$/m, '');
138152
if (hadExport) {
139-
const name = (node as ts.ClassDeclaration).name?.text;
153+
const name = (node as TS.ClassDeclaration).name?.text;
140154
if (name && !new RegExp(`export\\s*\\{\\s*${name}\\s*\\}`, 'm').test(cleaned)) {
141155
cleaned += `\nexport { ${name} };\n`;
142156
}

packages/vite/helpers/nativeclass-transformer-plugin.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import type { Plugin } from 'vite';
2-
import ts from 'typescript';
2+
import type * as TS from 'typescript';
33
import { isNativeESClassesEnabled, transformNativeClassSource } from './nativeclass-transform.js';
44
import { resolvePlatform } from './cli-flags.js';
5+
import { loadTypeScript, warnNativeClassSkipped, type TypeScript } from './typescript.js';
56

67
/**
78
* Look for `NativeClass` either as a bare identifier or as a `NativeClass(...)` call expression
89
* inside a `__decorate` array element. Returns true if the element is a NativeClass marker.
910
*/
10-
function isNativeClassDecoratorElement(el: ts.Expression): boolean {
11+
function isNativeClassDecoratorElement(ts: TypeScript, el: TS.Expression): boolean {
1112
if (ts.isIdentifier(el) && el.text === 'NativeClass') return true;
1213
if (ts.isCallExpression(el) && ts.isIdentifier(el.expression) && el.expression.text === 'NativeClass') return true;
1314
return false;
@@ -28,19 +29,19 @@ function isNativeClassDecoratorElement(el: ts.Expression): boolean {
2829
* decorators in the array (e.g. `__metadata("design:paramtypes", [])`), which
2930
* Angular's compiler always emits when a class has a constructor.
3031
*/
31-
function collectNativeClassDecorateEdits(code: string, sf: ts.SourceFile): { edits: Array<{ start: number; end: number; text: string }>; classNames: Set<string> } {
32+
function collectNativeClassDecorateEdits(ts: TypeScript, code: string, sf: TS.SourceFile): { edits: Array<{ start: number; end: number; text: string }>; classNames: Set<string> } {
3233
const edits: Array<{ start: number; end: number; text: string }> = [];
3334
const classNames = new Set<string>();
3435

35-
const visit = (node: ts.Node): void => {
36+
const visit = (node: TS.Node): void => {
3637
if (ts.isCallExpression(node)) {
3738
const callee = node.expression;
3839
const calleeName = ts.isIdentifier(callee) ? callee.text : ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) && ts.isIdentifier(callee.name) ? `${callee.expression.text}.${callee.name.text}` : undefined;
3940
if (calleeName && /^__decorate/.test(calleeName) && node.arguments.length >= 2) {
4041
const firstArg = node.arguments[0];
4142
const secondArg = node.arguments[1];
4243
if (ts.isArrayLiteralExpression(firstArg) && ts.isIdentifier(secondArg)) {
43-
const remaining = firstArg.elements.filter((el) => !isNativeClassDecoratorElement(el));
44+
const remaining = firstArg.elements.filter((el) => !isNativeClassDecoratorElement(ts, el));
4445
if (remaining.length !== firstArg.elements.length) {
4546
classNames.add(secondArg.text);
4647
const callStart = node.getStart(sf);
@@ -79,10 +80,10 @@ function collectNativeClassDecorateEdits(code: string, sf: ts.SourceFile): { edi
7980
* runtime global, so any pre-existing tslib `__extends` named import is removed
8081
* from this file's `tslib` import.
8182
*/
82-
function stripExtendsImportFromTslib(code: string): string {
83+
function stripExtendsImportFromTslib(ts: TypeScript, code: string): string {
8384
if (!/\b__extends\b/.test(code)) return code;
8485

85-
let sf: ts.SourceFile;
86+
let sf: TS.SourceFile;
8687
try {
8788
sf = ts.createSourceFile('extends-import-check.js', code, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
8889
} catch {
@@ -136,16 +137,21 @@ function stripExtendsImportFromTslib(code: string): string {
136137
*/
137138
export function postCleanupNativeClass(code: string, bareId: string, verbose = false): { code: string; map: null } | null {
138139
if (!code) return null;
139-
if (!code.includes('__decorate') || !code.includes('NativeClass')) return null;
140+
if (!code.includes('__decorate') || !/\bNativeClass\b/.test(code)) return null;
141+
const ts = loadTypeScript();
142+
if (!ts) {
143+
warnNativeClassSkipped(bareId);
144+
return null;
145+
}
140146

141-
let sf: ts.SourceFile;
147+
let sf: TS.SourceFile;
142148
try {
143149
sf = ts.createSourceFile(bareId + '.js', code, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
144150
} catch {
145151
return null;
146152
}
147153

148-
const { edits: decorateEdits, classNames: classNamesToDownlevel } = collectNativeClassDecorateEdits(code, sf);
154+
const { edits: decorateEdits, classNames: classNamesToDownlevel } = collectNativeClassDecorateEdits(ts, code, sf);
149155

150156
if (!classNamesToDownlevel.size) return null;
151157

@@ -167,13 +173,13 @@ export function postCleanupNativeClass(code: string, bareId: string, verbose = f
167173
try {
168174
// Use TypeScript AST to find and extract the class expression reliably
169175
const sf = ts.createSourceFile(bareId + '.js', output, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
170-
let classNode: ts.ClassExpression | ts.ClassDeclaration | undefined;
176+
let classNode: TS.ClassExpression | TS.ClassDeclaration | undefined;
171177
let baseName = '';
172178
let varDeclStart = -1;
173179
let varDeclEnd = -1;
174180
let aliasName = ''; // e.g. PDFViewDelegateImpl_1
175181

176-
const findClass = (node: ts.Node) => {
182+
const findClass = (node: TS.Node) => {
177183
if (classNode) return;
178184
// Match: var X = class X extends Y { ... }
179185
// or: var X = X_1 = class X extends Y { ... }
@@ -261,7 +267,7 @@ export function postCleanupNativeClass(code: string, bareId: string, verbose = f
261267
// `_super.call(this)` runs. Let the bare `__extends(...)` reference fall
262268
// through to the runtime global.
263269
if (downleveledAtLeastOne) {
264-
output = stripExtendsImportFromTslib(output);
270+
output = stripExtendsImportFromTslib(ts, output);
265271
}
266272

267273
if (output !== code) {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
vi.mock('./typescript.js', async (importOriginal) => {
4+
const actual = await importOriginal<typeof import('./typescript.js')>();
5+
return { ...actual, loadTypeScript: () => null };
6+
});
7+
8+
import { transformNativeClassSource } from './nativeclass-transform.js';
9+
import { postCleanupNativeClass } from './nativeclass-transformer-plugin.js';
10+
11+
const DECORATED_TS = `
12+
@NativeClass()
13+
export class TimerTargetImpl extends NSObject {
14+
tick() {}
15+
}
16+
`;
17+
18+
const DECORATED_JS = `
19+
let Impl = class Impl extends NSObject {};
20+
Impl = __decorate([NativeClass()], Impl);
21+
export { Impl };
22+
`;
23+
24+
describe('NativeClass transforms when typescript is not installed', () => {
25+
let warn: ReturnType<typeof vi.spyOn>;
26+
27+
beforeEach(() => {
28+
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
29+
});
30+
31+
afterEach(() => {
32+
warn.mockRestore();
33+
});
34+
35+
it('stays silent for sources that never needed the compiler', () => {
36+
expect(transformNativeClassSource('export const x = 1;', '/app/x.js')).toBeNull();
37+
expect(transformNativeClassSource('function ensureNativeClasses() {}', '/app/core.js')).toBeNull();
38+
expect(transformNativeClassSource('const Foo = __decorate([Component()], Foo);', '/app/foo.js')).toBeNull();
39+
expect(postCleanupNativeClass('export const x = 1;', '/app/x')).toBeNull();
40+
expect(postCleanupNativeClass('function ensureNativeClasses() {}\nlet Foo = __decorate([Component()], Foo);', '/app/core')).toBeNull();
41+
expect(warn).not.toHaveBeenCalled();
42+
});
43+
44+
it('leaves decorated sources untouched and warns once', () => {
45+
expect(transformNativeClassSource(DECORATED_TS, '/app/timer.ts')).toBeNull();
46+
expect(transformNativeClassSource(DECORATED_JS, '/node_modules/plugin/index.js')).toBeNull();
47+
expect(postCleanupNativeClass(DECORATED_JS, '/node_modules/plugin/index')).toBeNull();
48+
expect(warn).toHaveBeenCalledTimes(1);
49+
expect(String(warn.mock.calls[0][0])).toContain('/app/timer.ts');
50+
expect(String(warn.mock.calls[0][0])).toContain("'typescript'");
51+
});
52+
});

packages/vite/helpers/ts-config-paths.spec.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import fs from 'node:fs';
22
import os from 'node:os';
33
import path from 'node:path';
4-
import { afterEach, describe, expect, it } from 'vitest';
5-
import { createTsConfigPathsResolver, getTsConfigAliasRoots } from './ts-config-paths.js';
4+
import { afterEach, describe, expect, it, vi } from 'vitest';
5+
import { createTsConfigPathsResolver, getTsConfigAliasRoots, getTsConfigData } from './ts-config-paths.js';
66

77
const tempDirs: string[] = [];
88

@@ -84,3 +84,46 @@ describe('getTsConfigAliasRoots', () => {
8484
expect(getTsConfigAliasRoots({ paths: {} })).toEqual([]);
8585
});
8686
});
87+
88+
describe('getTsConfigData', () => {
89+
const cwd = process.cwd();
90+
91+
function createProject(): { root: string; realRoot: string } {
92+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ns-tsconfig-data-'));
93+
tempDirs.push(root);
94+
return { root, realRoot: fs.realpathSync(root) };
95+
}
96+
97+
afterEach(() => {
98+
process.chdir(cwd);
99+
});
100+
101+
it('reads path aliases from jsconfig.json when the project has no tsconfig', () => {
102+
const { root, realRoot } = createProject();
103+
fs.writeFileSync(path.join(root, 'jsconfig.json'), JSON.stringify({ compilerOptions: { baseUrl: './', paths: { '~/*': ['app/*'] } } }));
104+
process.chdir(root);
105+
106+
expect(getTsConfigData({ platform: 'ios' }).paths).toEqual({ '~/*': [path.join(realRoot, 'app', '*')] });
107+
});
108+
109+
it('prefers tsconfig.json over jsconfig.json', () => {
110+
const { root, realRoot } = createProject();
111+
fs.writeFileSync(path.join(root, 'tsconfig.json'), JSON.stringify({ compilerOptions: { paths: { '@ts/*': ['src/*'] } } }));
112+
fs.writeFileSync(path.join(root, 'jsconfig.json'), JSON.stringify({ compilerOptions: { paths: { '@js/*': ['app/*'] } } }));
113+
process.chdir(root);
114+
115+
expect(getTsConfigData({ platform: 'ios' }).paths).toEqual({ '@ts/*': [path.join(realRoot, 'src', '*')] });
116+
});
117+
118+
it('returns no aliases without warning when the project has neither file', () => {
119+
const { root } = createProject();
120+
process.chdir(root);
121+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
122+
try {
123+
expect(getTsConfigData({ platform: 'ios' })).toEqual({ paths: {}, baseUrl: '.' });
124+
expect(warn).not.toHaveBeenCalled();
125+
} finally {
126+
warn.mockRestore();
127+
}
128+
});
129+
});

packages/vite/helpers/ts-config-paths.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,10 @@ import type { Plugin } from 'vite';
44
import { getProjectFilePath, getProjectRootPath } from './project.js';
55
import { normalizeModuleId } from './normalize-id.js';
66

7-
let tsConfigPath: string;
8-
97
const projectRoot = getProjectRootPath();
108

119
// Read TypeScript path mappings
12-
function getTsConfigPaths(debugViteLogs: boolean = false) {
10+
function getTsConfigPaths(tsConfigPath: string, debugViteLogs: boolean = false) {
1311
try {
1412
if (debugViteLogs) console.log('📁 Parsing tsconfig at:', tsConfigPath);
1513
// The configDir should be the directory of the starting tsconfig file
@@ -395,17 +393,14 @@ type TsConfigOptions = {
395393
export const getTsConfigData = (options: TsConfigOptions) => {
396394
const verbose = !!options.verbose;
397395

398-
let candidatePath = getProjectFilePath('tsconfig.app.json');
399-
if (!fs.existsSync(candidatePath)) {
400-
candidatePath = getProjectFilePath('tsconfig.json');
401-
}
402-
tsConfigPath = candidatePath;
396+
// jsconfig.json is where a plain JavaScript app keeps the same `paths` aliases.
397+
const candidatePath = ['tsconfig.app.json', 'tsconfig.json', 'jsconfig.json'].map(getProjectFilePath).find((candidate) => fs.existsSync(candidate)) ?? null;
403398

404399
if (!cachedConfig || cachedPath !== candidatePath) {
405-
cachedConfig = getTsConfigPaths(verbose);
400+
cachedConfig = candidatePath ? getTsConfigPaths(candidatePath, verbose) : { paths: {}, baseUrl: '.' };
406401
cachedPath = candidatePath;
407402
if (verbose) {
408-
console.log('📁 Loaded TypeScript path configuration');
403+
console.log(candidatePath ? '📁 Loaded TypeScript path configuration' : '📁 No tsconfig.json or jsconfig.json found; no path aliases configured');
409404
}
410405
}
411406

0 commit comments

Comments
 (0)