From f12beeb2ecc8ab8dc7377981555fa13e8c62f14b Mon Sep 17 00:00:00 2001 From: Chau Tran Date: Mon, 1 Jan 2024 21:53:29 -0600 Subject: [PATCH] feat(vite-plugin-angular): introduce experimental support for .ng format (#823) Co-authored-by: Brandon Roberts --- .prettierrc | 10 +- apps/ng-app/.eslintrc.json | 36 ++ apps/ng-app/index.html | 14 + apps/ng-app/project.json | 63 ++++ apps/ng-app/src/app/another-one.ng | 15 + apps/ng-app/src/app/app.component.ng | 78 +++++ apps/ng-app/src/app/app.config.ts | 6 + apps/ng-app/src/app/doubled.ng | 9 + apps/ng-app/src/app/hello.ng | 11 + apps/ng-app/src/app/highlight.ng | 13 + apps/ng-app/src/assets/.gitkeep | 0 apps/ng-app/src/favicon.ico | Bin 0 -> 15086 bytes apps/ng-app/src/main.ts | 8 + apps/ng-app/src/styles.css | 1 + apps/ng-app/src/vite-env.d.ts | 38 +++ apps/ng-app/tsconfig.app.json | 10 + apps/ng-app/tsconfig.editor.json | 7 + apps/ng-app/tsconfig.json | 30 ++ apps/ng-app/vite.config.ts | 31 ++ package.json | 10 +- .../template-angular-v17/src/vite-env.d.ts | 30 ++ .../src/lib/angular-vite-plugin.ts | 66 +++- .../authoring/__snapshots__/ng.spec.ts.snap | 137 ++++++++ .../src/lib/authoring/ng.spec.ts | 72 ++++ .../src/lib/authoring/ng.ts | 316 ++++++++++++++++++ packages/vite-plugin-angular/src/lib/host.ts | 42 ++- pnpm-lock.yaml | 291 ++++++++++++++-- tsconfig.base.json | 2 +- 28 files changed, 1317 insertions(+), 29 deletions(-) create mode 100644 apps/ng-app/.eslintrc.json create mode 100644 apps/ng-app/index.html create mode 100644 apps/ng-app/project.json create mode 100644 apps/ng-app/src/app/another-one.ng create mode 100644 apps/ng-app/src/app/app.component.ng create mode 100644 apps/ng-app/src/app/app.config.ts create mode 100644 apps/ng-app/src/app/doubled.ng create mode 100644 apps/ng-app/src/app/hello.ng create mode 100644 apps/ng-app/src/app/highlight.ng create mode 100644 apps/ng-app/src/assets/.gitkeep create mode 100644 apps/ng-app/src/favicon.ico create mode 100644 apps/ng-app/src/main.ts create mode 100644 apps/ng-app/src/styles.css create mode 100644 apps/ng-app/src/vite-env.d.ts create mode 100644 apps/ng-app/tsconfig.app.json create mode 100644 apps/ng-app/tsconfig.editor.json create mode 100644 apps/ng-app/tsconfig.json create mode 100644 apps/ng-app/vite.config.ts create mode 100644 packages/vite-plugin-angular/src/lib/authoring/__snapshots__/ng.spec.ts.snap create mode 100644 packages/vite-plugin-angular/src/lib/authoring/ng.spec.ts create mode 100644 packages/vite-plugin-angular/src/lib/authoring/ng.ts diff --git a/.prettierrc b/.prettierrc index 544138be4..3e099db38 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,11 @@ { - "singleQuote": true + "singleQuote": true, + "overrides": [ + { + "files": "*.ng", + "options": { + "parser": "html" + } + } + ] } diff --git a/apps/ng-app/.eslintrc.json b/apps/ng-app/.eslintrc.json new file mode 100644 index 000000000..7eef2ce74 --- /dev/null +++ b/apps/ng-app/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/apps/ng-app/index.html b/apps/ng-app/index.html new file mode 100644 index 000000000..578fcad18 --- /dev/null +++ b/apps/ng-app/index.html @@ -0,0 +1,14 @@ + + + + + ng-app + + + + + + + + + diff --git a/apps/ng-app/project.json b/apps/ng-app/project.json new file mode 100644 index 000000000..af5693334 --- /dev/null +++ b/apps/ng-app/project.json @@ -0,0 +1,63 @@ +{ + "name": "ng-app", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "prefix": "app", + "sourceRoot": "apps/ng-app/src", + "tags": [], + "targets": { + "build": { + "executor": "@nx/vite:build", + "outputs": [ + "{options.outputPath}", + "{workspaceRoot}/dist/apps/ng-app/.nitro", + "{workspaceRoot}/dist/apps/ng-app/ssr", + "{workspaceRoot}/dist/apps/ng-app/analog" + ], + "options": { + "configFile": "apps/ng-app/vite.config.ts", + "outputPath": "dist/apps/ng-app/client" + }, + "defaultConfiguration": "production", + "configurations": { + "development": { + "mode": "development" + }, + "production": { + "sourcemap": false, + "mode": "production" + } + } + }, + "serve": { + "executor": "@nx/vite:dev-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "ng-app:build", + "port": 3000 + }, + "configurations": { + "development": { + "buildTarget": "ng-app:build:development", + "hmr": true + }, + "production": { + "buildTarget": "ng-app:build:production" + } + } + }, + "extract-i18n": { + "executor": "@angular-devkit/build-angular:extract-i18n", + "options": { + "buildTarget": "ng-app:build" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["apps/ng-app/**/*.ts", "apps/ng-app/**/*.html"] + } + } + } +} diff --git a/apps/ng-app/src/app/another-one.ng b/apps/ng-app/src/app/another-one.ng new file mode 100644 index 000000000..7876f42af --- /dev/null +++ b/apps/ng-app/src/app/another-one.ng @@ -0,0 +1,15 @@ + + + + + diff --git a/apps/ng-app/src/app/app.component.ng b/apps/ng-app/src/app/app.component.ng new file mode 100644 index 000000000..55429ccde --- /dev/null +++ b/apps/ng-app/src/app/app.component.ng @@ -0,0 +1,78 @@ + + + + + diff --git a/apps/ng-app/src/app/app.config.ts b/apps/ng-app/src/app/app.config.ts new file mode 100644 index 000000000..1c0c9422f --- /dev/null +++ b/apps/ng-app/src/app/app.config.ts @@ -0,0 +1,6 @@ +import { provideHttpClient } from '@angular/common/http'; +import { ApplicationConfig } from '@angular/core'; + +export const appConfig: ApplicationConfig = { + providers: [provideHttpClient()], +}; diff --git a/apps/ng-app/src/app/doubled.ng b/apps/ng-app/src/app/doubled.ng new file mode 100644 index 000000000..4b0fd05d0 --- /dev/null +++ b/apps/ng-app/src/app/doubled.ng @@ -0,0 +1,9 @@ + diff --git a/apps/ng-app/src/app/hello.ng b/apps/ng-app/src/app/hello.ng new file mode 100644 index 000000000..1d07e802d --- /dev/null +++ b/apps/ng-app/src/app/hello.ng @@ -0,0 +1,11 @@ + + + diff --git a/apps/ng-app/src/app/highlight.ng b/apps/ng-app/src/app/highlight.ng new file mode 100644 index 000000000..0140902b9 --- /dev/null +++ b/apps/ng-app/src/app/highlight.ng @@ -0,0 +1,13 @@ + diff --git a/apps/ng-app/src/assets/.gitkeep b/apps/ng-app/src/assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/ng-app/src/favicon.ico b/apps/ng-app/src/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..317ebcb2336e0833a22dddf0ab287849f26fda57 GIT binary patch literal 15086 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 + console.error(err) +); diff --git a/apps/ng-app/src/styles.css b/apps/ng-app/src/styles.css new file mode 100644 index 000000000..90d4ee007 --- /dev/null +++ b/apps/ng-app/src/styles.css @@ -0,0 +1 @@ +/* You can add global styles to this file, and also import other style files */ diff --git a/apps/ng-app/src/vite-env.d.ts b/apps/ng-app/src/vite-env.d.ts new file mode 100644 index 000000000..dc307357c --- /dev/null +++ b/apps/ng-app/src/vite-env.d.ts @@ -0,0 +1,38 @@ +/// + +interface ImportMetaEnv { + readonly VITE_ANALOG_PUBLIC_BASE_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} + +declare global { + import type { Component, Directive, Pipe } from '@angular/core'; + + interface Window { + defineComponentMetadata: ( + metadata: Omit< + Component, + | 'template' + | 'templateUrl' + | 'host' + | 'standalone' + | 'changeDetection' + | 'styleUrls' + | 'styleUrl' + | 'styles' + > + ) => void; + defineDirectiveMetadata: ( + metadata: Omit + ) => void; + definePipeMetadata: (metadata: Omit) => void; + } +} + +declare module '*.ng' { + const cmp = any; + export default cmp; +} diff --git a/apps/ng-app/tsconfig.app.json b/apps/ng-app/tsconfig.app.json new file mode 100644 index 000000000..fff4a41d4 --- /dev/null +++ b/apps/ng-app/tsconfig.app.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts"], + "exclude": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/apps/ng-app/tsconfig.editor.json b/apps/ng-app/tsconfig.editor.json new file mode 100644 index 000000000..4ee639340 --- /dev/null +++ b/apps/ng-app/tsconfig.editor.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "types": [] + } +} diff --git a/apps/ng-app/tsconfig.json b/apps/ng-app/tsconfig.json new file mode 100644 index 000000000..cd3727d6f --- /dev/null +++ b/apps/ng-app/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.editor.json" + } + ], + "extends": "../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/apps/ng-app/vite.config.ts b/apps/ng-app/vite.config.ts new file mode 100644 index 000000000..659492c97 --- /dev/null +++ b/apps/ng-app/vite.config.ts @@ -0,0 +1,31 @@ +/// + +import { defineConfig } from 'vite'; +import analog from '@analogjs/platform'; + +// https://vitejs.dev/config/ +export default defineConfig(({ mode }) => ({ + publicDir: 'src/assets', + build: { + target: ['es2020'], + }, + resolve: { + mainFields: ['module'], + }, + plugins: [ + analog({ + ssr: false, + vite: { experimental: { dangerouslySupportNgFormat: true } }, + }), + ], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['src/test.ts'], + include: ['**/*.spec.ts'], + reporters: ['default'], + }, + define: { + 'import.meta.vitest': mode !== 'production', + }, +})); diff --git a/package.json b/package.json index 5703683be..0ee8ebb65 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0", "contributors:add": "all-contributors add", "contributors:generate": "all-contributors generate", - "prettify": "prettier --write ." + "prettify": "prettier --write .", + "build:test": "nx build ng-app --skip-nx-cache" }, "engines": { "node": "^18.13.0", @@ -103,12 +104,14 @@ "@nx/plugin": "17.1.3", "@nx/vite": "17.1.3", "@nx/web": "17.1.3", + "@phenomnomnominal/tsquery": "^6.1.3", "@schematics/angular": "^17.0.0", "@swc-node/register": "~1.6.7", "@swc/cli": "0.1.62", "@swc/core": "~1.3.85", "@swc/helpers": "0.5.1", "@types/babel__core": "^7.20.0", + "@types/hast": "^3.0.3", "@types/jest": "29.4.4", "@types/marked": "^5.0.0", "@types/node": "18.11.18", @@ -132,6 +135,9 @@ "fs-extra": "^11.1.1", "h3": "^1.8.2", "happy-dom": "^12.10.3", + "hast-util-from-html": "^2.0.1", + "hast-util-from-parse5": "^8.0.1", + "hast-util-to-html": "^9.0.0", "jest": "^29.5.0", "jest-environment-jsdom": "^29.5.0", "jsdom": "22.1.0", @@ -142,6 +148,7 @@ "ng-packagr": "^17.0.0", "nitropack": "^2.6.0", "nx": "17.1.3", + "parse5": "^7.1.2", "playwright": "^1.30.0", "postcss": "^8.4.21", "postcss-import": "~15.1.0", @@ -154,6 +161,7 @@ "start-server-and-test": "^1.15.4", "tailwindcss": "^3.0.2", "ts-jest": "29.1.0", + "ts-morph": "^21.0.1", "ts-node": "10.9.1", "typescript": "~5.2.0", "vite": "5.0.0", diff --git a/packages/create-analog/template-angular-v17/src/vite-env.d.ts b/packages/create-analog/template-angular-v17/src/vite-env.d.ts index 11f02fe2a..a73290570 100644 --- a/packages/create-analog/template-angular-v17/src/vite-env.d.ts +++ b/packages/create-analog/template-angular-v17/src/vite-env.d.ts @@ -1 +1,31 @@ /// + +// Uncomment the lines below to enable types for experimental .ng format support +// declare global { +// import type { Component, Directive, Pipe } from '@angular/core'; + +// interface Window { +// defineComponentMetadata: ( +// metadata: Omit< +// Component, +// | 'template' +// | 'templateUrl' +// | 'host' +// | 'standalone' +// | 'changeDetection' +// | 'styleUrls' +// | 'styleUrl' +// | 'styles' +// > +// ) => void; +// defineDirectiveMetadata: ( +// metadata: Omit +// ) => void; +// definePipeMetadata: (metadata: Omit) => void; +// } +// } + +// declare module '*.ng' { +// const cmp = any; +// export default cmp; +// } diff --git a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts index e6c90e589..49f178d98 100644 --- a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts +++ b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts @@ -1,4 +1,11 @@ -import { ModuleNode, Plugin, PluginContainer, ViteDevServer } from 'vite'; +import { + ModuleNode, + normalizePath, + Plugin, + PluginContainer, + UserConfig, + ViteDevServer, +} from 'vite'; import { CompilerHost, NgtscProgram } from '@angular/compiler-cli'; import { transformAsync } from '@babel/core'; @@ -34,6 +41,12 @@ export interface PluginOptions { */ tsTransformers?: ts.CustomTransformers; }; + experimental?: { + /** + * Enable experimental support for .ng file format! Use as your own risk! + */ + dangerouslySupportNgFormat?: boolean; + }; supportedBrowsers?: string[]; transformFilter?: (code: string, id: string) => boolean; } @@ -51,7 +64,7 @@ type FileEmitter = (file: string) => Promise; * Match .(c or m)ts, .ts extensions with an optional ? for query params * Ignore .tsx extensions */ -const TS_EXT_REGEX = /\.[cm]?ts[^x]?\??/; +const TS_EXT_REGEX = /\.[cm]?(ts|ng)[^x]?\??/; export function angular(options?: PluginOptions): Plugin[] { /** @@ -76,6 +89,7 @@ export function angular(options?: PluginOptions): Plugin[] { }, supportedBrowsers: options?.supportedBrowsers ?? ['safari 15'], jit: options?.jit, + supportNgFormat: options?.experimental?.dangerouslySupportNgFormat, }; // The file emitter created during `onStart` that will be used during the build in `onLoad` callbacks for TS files @@ -92,6 +106,7 @@ export function angular(options?: PluginOptions): Plugin[] { } = require('@ngtools/webpack/src/ivy/host'); let compilerCli: typeof import('@angular/compiler-cli'); + let userConfig: UserConfig; let rootNames: string[]; let host: ts.CompilerHost; let nextProgram: NgtscProgram | undefined | ts.Program; @@ -114,6 +129,7 @@ export function angular(options?: PluginOptions): Plugin[] { name: '@analogjs/vite-plugin-angular', async config(config, { command }) { watchMode = command === 'serve'; + userConfig = config; pluginOptions.tsconfig = options?.tsconfig ?? @@ -167,7 +183,7 @@ export function angular(options?: PluginOptions): Plugin[] { cssPlugin = plugins.find((plugin) => plugin.name === 'vite:css'); } - setupCompilation(); + setupCompilation(userConfig); // Only store cache if in watch mode if (watchMode) { @@ -285,7 +301,7 @@ export function angular(options?: PluginOptions): Plugin[] { } } - const typescriptResult = await fileEmitter!(id); + const typescriptResult = fileEmitter && (await fileEmitter!(id)); // return fileEmitter let data = typescriptResult?.content ?? ''; @@ -327,6 +343,16 @@ export function angular(options?: PluginOptions): Plugin[] { /for\s+await\s*\(|async\s+function\s*\*/.test(data); const useInputSourcemap = (!isProd ? undefined : false) as undefined; + if ( + id.includes('.ng') && + pluginOptions.supportNgFormat && + fileEmitter + ) { + sourceFileCache.invalidate([`${id}.ts`]); + const ngFileResult = await fileEmitter!(`${id}.ts`); + data = ngFileResult?.content || ''; + } + if (!forceAsyncTransformation && !isProd) { return { code: data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''), @@ -383,7 +409,26 @@ export function angular(options?: PluginOptions): Plugin[] { }), ].filter(Boolean) as Plugin[]; - function setupCompilation() { + function findNgFiles(config: UserConfig) { + if (!pluginOptions.supportNgFormat) { + return []; + } + + const fg = require('fast-glob'); + const root = normalizePath( + path.resolve(pluginOptions.workspaceRoot, config.root || '.') + ); + const ngFiles: string[] = fg + .sync([`${root}/**/*.ng`], { + dot: true, + }) + .map((file: string) => `${file}.ts`); + + return ngFiles; + } + + function setupCompilation(config: UserConfig) { + const ngFiles = findNgFiles(config); const { options: tsCompilerOptions, rootNames: rn } = compilerCli.readConfiguration(pluginOptions.tsconfig, { suppressOutputPathCheck: true, @@ -398,7 +443,14 @@ export function angular(options?: PluginOptions): Plugin[] { supportTestBed: false, }); - rootNames = rn; + if (pluginOptions.supportNgFormat) { + // Experimental Local Compilation is necessary + // for the Angular compiler to work with + // AOT and virtually compiled .ng files. + tsCompilerOptions.compilationMode = 'experimental-local'; + } + + rootNames = rn.concat(ngFiles); compilerOptions = tsCompilerOptions; host = ts.createIncrementalCompilerHost(compilerOptions); @@ -409,6 +461,8 @@ export function angular(options?: PluginOptions): Plugin[] { if (!jit) { augmentHostWithResources(host, styleTransform, { inlineStylesExtension: pluginOptions.inlineStylesExtension, + supportNgFormat: pluginOptions.supportNgFormat, + isProd: isProd, }); } } diff --git a/packages/vite-plugin-angular/src/lib/authoring/__snapshots__/ng.spec.ts.snap b/packages/vite-plugin-angular/src/lib/authoring/__snapshots__/ng.spec.ts.snap new file mode 100644 index 000000000..a9796b38d --- /dev/null +++ b/packages/vite-plugin-angular/src/lib/authoring/__snapshots__/ng.spec.ts.snap @@ -0,0 +1,137 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`authoring ng file > should process component as ng file 1`] = ` +"import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { signal } from "@angular/core"; + +@Component({ + standalone: true, + selector: 'virtual,Virtual,VIRTUAL', + changeDetection: ChangeDetectionStrategy.OnPush, + template: \` +
Component
+

{{ counter() }}

\`, + imports: [] +}) +export default class AnalogNgEntity { + constructor() { + let counter = signal(0); + this.counter = counter; + } + + protected counter; +} +" +`; + +exports[`authoring ng file > should process directive as ng file 1`] = ` +"import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { inject, ElementRef, afterNextRender } from "@angular/core"; +import { Directive } from "@angular/core"; + +@Directive({ + standalone: true, + selector: 'input[directive]' +}) +export default class AnalogNgEntity { + constructor() { + let elRef = inject(ElementRef); + this.elRef = elRef; + afterNextRender(() => { + elRef.nativeElement.focus(); + }); + } + + protected elRef; +} +" +`; + +exports[`authoring ng file > should process pipe as ng file 1`] = ` +"import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { Pipe } from "@angular/core"; + +@Pipe({ + standalone: true, + name: 'doubled' +}) +export default class AnalogNgEntity { + constructor() { + function transform(value: number) { + return value * 2; + } + this.transform = transform.bind(this); + } + + protected transform; +} +" +`; + +exports[`authoring ng file should process component as ng file 1`] = ` +"import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { signal } from \\"@angular/core\\"; + +@Component({ + standalone: true, + selector: 'virtual,Virtual,VIRTUAL', + changeDetection: ChangeDetectionStrategy.OnPush, + template: \` +
Component
+

{{ counter() }}

\`, + imports: [] +}) +export default class AnalogNgEntity { + constructor() { + let counter = signal(0); + this.counter = counter; + } + + protected counter; +} +" +`; + +exports[`authoring ng file should process directive as ng file 1`] = ` +"import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { inject, ElementRef, afterNextRender } from \\"@angular/core\\"; +import { Directive } from \\"@angular/core\\"; + +@Directive({ + standalone: true, + selector: 'input[directive]' +}) +export default class AnalogNgEntity { + constructor() { + let elRef = inject(ElementRef); + this.elRef = elRef; + afterNextRender(() => { + elRef.nativeElement.focus(); + }); + } + + protected elRef; +} +" +`; + +exports[`authoring ng file should process pipe as ng file 1`] = ` +"import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { Pipe } from \\"@angular/core\\"; + +@Pipe({ + standalone: true, + name: 'doubled' +}) +export default class AnalogNgEntity { + constructor() { + function transform(value: number) { + return value * 2; + } + this.transform = transform.bind(this); + } + + protected transform; +} +" +`; diff --git a/packages/vite-plugin-angular/src/lib/authoring/ng.spec.ts b/packages/vite-plugin-angular/src/lib/authoring/ng.spec.ts new file mode 100644 index 000000000..f561ca713 --- /dev/null +++ b/packages/vite-plugin-angular/src/lib/authoring/ng.spec.ts @@ -0,0 +1,72 @@ +import { processNgFile } from './ng'; + +const COMPONENT_CONTENT = ` + + + + + +`; + +const DIRECTIVE_CONTENT = ` + +`; + +const PIPE_CONTENT = ` + +`; + +describe('authoring ng file', () => { + it('should process component as ng file', () => { + const source = processNgFile('virtual.ng.ts', COMPONENT_CONTENT); + expect(source).toContain('Component'); + expect(source).toMatchSnapshot(); + }); + + it('should process directive as ng file', () => { + const source = processNgFile('virtual.ng.ts', DIRECTIVE_CONTENT); + expect(source).toContain('Directive'); + expect(source).toMatchSnapshot(); + }); + + it('should process pipe as ng file', () => { + const source = processNgFile('virtual.ng.ts', PIPE_CONTENT); + expect(source).toContain('Pipe'); + expect(source).toMatchSnapshot(); + }); +}); diff --git a/packages/vite-plugin-angular/src/lib/authoring/ng.ts b/packages/vite-plugin-angular/src/lib/authoring/ng.ts new file mode 100644 index 000000000..2bf6bff94 --- /dev/null +++ b/packages/vite-plugin-angular/src/lib/authoring/ng.ts @@ -0,0 +1,316 @@ +import { + ClassDeclaration, + ConstructorDeclaration, + Expression, + FunctionDeclaration, + Node, + ObjectLiteralExpression, + Project, + Scope, + StructureKind, +} from 'ts-morph'; + +const SCRIPT_TAG_REGEX = /