Просмотр исходного кода

Merge branch 'minor' into major

Michael Bromley 3 лет назад
Родитель
Сommit
081a01ae93

+ 6 - 0
packages/admin-ui/src/lib/core/src/data/providers/interceptor.ts

@@ -93,6 +93,12 @@ export class DefaultInterceptor implements HttpInterceptor {
                 const firstCode: string = graqhQLErrors[0]?.extensions?.code;
                 if (firstCode === 'FORBIDDEN') {
                     this.authService.logOut().subscribe(() => {
+                        const { loginUrl } = getAppConfig();
+                        if (loginUrl) {
+                            window.location.href = loginUrl;
+                            return;
+                        }
+
                         if (!window.location.pathname.includes('login')) {
                             const path = graqhQLErrors[0].path.join(' > ');
                             this.displayErrorNotification(_(`error.403-forbidden`), { path });

+ 3 - 0
packages/admin-ui/src/lib/settings/src/components/admin-detail/admin-detail.component.scss

@@ -0,0 +1,3 @@
+ul.nav {
+    overflow-x: auto;
+}

+ 1 - 0
packages/asset-server-plugin/src/constants.ts

@@ -1 +1,2 @@
 export const loggerCtx = 'AssetServerPlugin';
+export const DEFAULT_CACHE_HEADER = 'public, max-age=15552000';

+ 17 - 1
packages/asset-server-plugin/src/plugin.ts

@@ -16,7 +16,7 @@ import fs from 'fs-extra';
 import path from 'path';
 
 import { getValidFormat } from './common';
-import { loggerCtx } from './constants';
+import { DEFAULT_CACHE_HEADER, loggerCtx } from './constants';
 import { defaultAssetStorageStrategyFactory } from './default-asset-storage-strategy-factory';
 import { HashedAssetNamingStrategy } from './hashed-asset-naming-strategy';
 import { SharpAssetPreviewStrategy } from './sharp-asset-preview-strategy';
@@ -151,6 +151,7 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap {
         { name: 'large', width: 800, height: 800, mode: 'resize' },
     ];
     private static options: AssetServerOptions;
+    private cacheHeader: string;
 
     /**
      * @description
@@ -196,6 +197,20 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap {
             }
         }
 
+        // Configure Cache-Control header
+        const { cacheHeader } = AssetServerPlugin.options;
+        if (!cacheHeader) {
+            this.cacheHeader = DEFAULT_CACHE_HEADER;
+        } else {
+            if (typeof cacheHeader === 'string') {
+                this.cacheHeader = cacheHeader;
+            } else {
+                this.cacheHeader = [cacheHeader.restriction, `max-age: ${cacheHeader.maxAge}`]
+                    .filter(value => !!value)
+                    .join(', ');
+            }
+        }
+
         const cachePath = path.join(AssetServerPlugin.options.assetUploadDir, this.cacheDir);
         fs.ensureDirSync(cachePath);
     }
@@ -232,6 +247,7 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap {
                 }
                 res.contentType(mimeType);
                 res.setHeader('content-security-policy', `default-src 'self'`);
+                res.setHeader('Cache-Control', this.cacheHeader);
                 res.send(file);
             } catch (e: any) {
                 const err = new Error('File not found');

+ 29 - 0
packages/asset-server-plugin/src/types.ts

@@ -43,6 +43,26 @@ export interface ImageTransformPreset {
     mode: ImageTransformMode;
 }
 
+/**
+ * @description
+ * A configuration option for the Cache-Control header in the AssetServerPlugin asset response.
+ *
+ * @docsCategory AssetServerPlugin
+ */
+export type CacheConfig = {
+    /**
+     * @description
+     * The max-age=N response directive indicates that the response remains fresh until N seconds after the response is generated.
+     */
+    maxAge: number;
+    /**
+     * @description
+     * The `private` response directive indicates that the response can be stored only in a private cache (e.g. local caches in browsers).
+     * The `public` response directive indicates that the response can be stored in a shared cache.
+     */
+    restriction?: 'public' | 'private';
+};
+
 /**
  * @description
  * The configuration options for the AssetServerPlugin.
@@ -117,4 +137,13 @@ export interface AssetServerOptions {
     storageStrategyFactory?: (
         options: AssetServerOptions,
     ) => AssetStorageStrategy | Promise<AssetStorageStrategy>;
+    /**
+     * @description
+     * Configures the `Cache-Control` directive for response to control caching in browsers and shared caches (e.g. Proxies, CDNs).
+     * Defaults to publicly cached for 6 months.
+     *
+     * @default 'public, max-age=15552000'
+     * @since 1.9.3
+     */
+    cacheHeader?: CacheConfig | string;
 }

+ 99 - 1
packages/core/e2e/auth.e2e-spec.ts

@@ -1,6 +1,6 @@
 /* tslint:disable:no-non-null-assertion */
 import { SUPER_ADMIN_USER_IDENTIFIER, SUPER_ADMIN_USER_PASSWORD } from '@vendure/common/lib/shared-constants';
-import { createTestEnvironment } from '@vendure/testing';
+import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
 import { DocumentNode } from 'graphql';
 import gql from 'graphql-tag';
 import path from 'path';
@@ -11,9 +11,11 @@ import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-conf
 import { ProtectedFieldsPlugin, transactions } from './fixtures/test-plugins/with-protected-field-resolver';
 import { ErrorCode, Permission } from './graphql/generated-e2e-admin-types';
 import * as Codegen from './graphql/generated-e2e-admin-types';
+import * as CodegenShop from './graphql/generated-e2e-shop-types';
 import {
     ATTEMPT_LOGIN,
     CREATE_ADMINISTRATOR,
+    CREATE_CUSTOMER,
     CREATE_CUSTOMER_GROUP,
     CREATE_PRODUCT,
     CREATE_ROLE,
@@ -174,6 +176,102 @@ describe('Authorization & permissions', () => {
         });
     });
 
+    describe('administrator and customer users with the same email address', () => {
+        const emailAddress = 'same-email@test.com';
+        const adminPassword = 'admin-password';
+        const customerPassword = 'customer-password';
+
+        const loginErrorGuard: ErrorResultGuard<Codegen.CurrentUserFragment> = createErrorResultGuard(
+            input => !!input.identifier,
+        );
+
+        beforeAll(async () => {
+            await adminClient.asSuperAdmin();
+
+            await adminClient.query<
+                Codegen.CreateAdministratorMutation,
+                Codegen.CreateAdministratorMutationVariables
+            >(CREATE_ADMINISTRATOR, {
+                input: {
+                    emailAddress,
+                    firstName: 'First',
+                    lastName: 'Last',
+                    password: adminPassword,
+                    roleIds: ['1'],
+                },
+            });
+
+            await adminClient.query<Codegen.CreateCustomerMutation, Codegen.CreateCustomerMutationVariables>(
+                CREATE_CUSTOMER,
+                {
+                    input: {
+                        emailAddress,
+                        firstName: 'First',
+                        lastName: 'Last',
+                    },
+                    password: customerPassword,
+                },
+            );
+        });
+
+        beforeEach(async () => {
+            await adminClient.asAnonymousUser();
+            await shopClient.asAnonymousUser();
+        });
+
+        it('can log in as an administrator', async () => {
+            const loginResult = await adminClient.query<
+                CodegenShop.AttemptLoginMutation,
+                CodegenShop.AttemptLoginMutationVariables
+            >(ATTEMPT_LOGIN, {
+                username: emailAddress,
+                password: adminPassword,
+            });
+
+            loginErrorGuard.assertSuccess(loginResult.login);
+            expect(loginResult.login.identifier).toEqual(emailAddress);
+        });
+
+        it('can log in as a customer', async () => {
+            const loginResult = await shopClient.query<
+                CodegenShop.AttemptLoginMutation,
+                CodegenShop.AttemptLoginMutationVariables
+            >(ATTEMPT_LOGIN, {
+                username: emailAddress,
+                password: customerPassword,
+            });
+
+            loginErrorGuard.assertSuccess(loginResult.login);
+            expect(loginResult.login.identifier).toEqual(emailAddress);
+        });
+
+        it('cannot log in as an administrator using a customer password', async () => {
+            const loginResult = await adminClient.query<
+                CodegenShop.AttemptLoginMutation,
+                CodegenShop.AttemptLoginMutationVariables
+            >(ATTEMPT_LOGIN, {
+                username: emailAddress,
+                password: customerPassword,
+            });
+
+            loginErrorGuard.assertErrorResult(loginResult.login);
+            expect(loginResult.login.errorCode).toEqual(ErrorCode.INVALID_CREDENTIALS_ERROR);
+        });
+
+        it('cannot log in as a customer using an administrator password', async () => {
+            const loginResult = await shopClient.query<
+                CodegenShop.AttemptLoginMutation,
+                CodegenShop.AttemptLoginMutationVariables
+            >(ATTEMPT_LOGIN, {
+                username: emailAddress,
+                password: adminPassword,
+            });
+
+            loginErrorGuard.assertErrorResult(loginResult.login);
+            expect(loginResult.login.errorCode).toEqual(ErrorCode.INVALID_CREDENTIALS_ERROR);
+        });
+    });
+
     describe('protected field resolvers', () => {
         let readCatalogAdmin: { identifier: string; password: string };
         let transactionsAdmin: { identifier: string; password: string };

+ 39 - 3
packages/core/e2e/customer.e2e-spec.ts

@@ -29,6 +29,7 @@ import {
 } from './graphql/generated-e2e-shop-types';
 import {
     CREATE_ADDRESS,
+    CREATE_ADMINISTRATOR,
     CREATE_CUSTOMER,
     DELETE_CUSTOMER,
     DELETE_CUSTOMER_NOTE,
@@ -123,16 +124,51 @@ describe('Customer resolver', () => {
     });
 
     it('customer resolver resolves User', async () => {
+        const emailAddress = 'same-email@test.com';
+
+        // Create an administrator with the same email first in order to ensure the right user is resolved.
+        // This test also validates that a customer can be created with the same identifier
+        // of an existing administrator
+        const { createAdministrator } = await adminClient.query<
+            Codegen.CreateAdministratorMutation,
+            Codegen.CreateAdministratorMutationVariables
+        >(CREATE_ADMINISTRATOR, {
+            input: {
+                emailAddress,
+                firstName: 'First',
+                lastName: 'Last',
+                password: '123',
+                roleIds: ['1'],
+            },
+        });
+
+        expect(createAdministrator.emailAddress).toEqual(emailAddress);
+
+        const { createCustomer } = await adminClient.query<
+            Codegen.CreateCustomerMutation,
+            Codegen.CreateCustomerMutationVariables
+        >(CREATE_CUSTOMER, {
+            input: {
+                emailAddress,
+                firstName: 'New',
+                lastName: 'Customer',
+            },
+            password: 'test',
+        });
+
+        customerErrorGuard.assertSuccess(createCustomer);
+        expect(createCustomer.emailAddress).toEqual(emailAddress);
+
         const { customer } = await adminClient.query<
             Codegen.GetCustomerWithUserQuery,
             Codegen.GetCustomerWithUserQueryVariables
         >(GET_CUSTOMER_WITH_USER, {
-            id: firstCustomer.id,
+            id: createCustomer.id,
         });
 
         expect(customer!.user).toEqual({
-            id: 'T_2',
-            identifier: firstCustomer.emailAddress,
+            id: createCustomer.user?.id,
+            identifier: emailAddress,
             verified: true,
         });
     });

+ 4 - 0
packages/core/e2e/graphql/shared-definitions.ts

@@ -208,6 +208,10 @@ export const ATTEMPT_LOGIN = gql`
     mutation AttemptLogin($username: String!, $password: String!, $rememberMe: Boolean) {
         login(username: $username, password: $password, rememberMe: $rememberMe) {
             ...CurrentUser
+            ... on ErrorResult {
+                errorCode
+                message
+            }
         }
     }
     ${CURRENT_USER_FRAGMENT}

+ 1 - 1
packages/core/src/api/resolvers/entity/customer-entity.resolver.ts

@@ -56,7 +56,7 @@ export class CustomerEntityResolver {
             return customer.user;
         }
 
-        return this.userService.getUserByEmailAddress(ctx, customer.emailAddress);
+        return this.userService.getUserByEmailAddress(ctx, customer.emailAddress, 'customer');
     }
 }
 

+ 5 - 9
packages/core/src/config/auth/native-authentication-strategy.ts

@@ -30,12 +30,15 @@ export class NativeAuthenticationStrategy implements AuthenticationStrategy<Nati
 
     private connection: TransactionalConnection;
     private passwordCipher: import('../../service/helpers/password-cipher/password-cipher').PasswordCipher;
+    private userService: import('../../service/services/user.service').UserService;
 
     async init(injector: Injector) {
         this.connection = injector.get(TransactionalConnection);
-        // This is lazily-loaded to avoid a circular dependency
+        // These are lazily-loaded to avoid a circular dependency
         const { PasswordCipher } = await import('../../service/helpers/password-cipher/password-cipher');
+        const { UserService } = await import('../../service/services/user.service');
         this.passwordCipher = injector.get(PasswordCipher);
+        this.userService = injector.get(UserService);
     }
 
     defineInputType(): DocumentNode {
@@ -48,7 +51,7 @@ export class NativeAuthenticationStrategy implements AuthenticationStrategy<Nati
     }
 
     async authenticate(ctx: RequestContext, data: NativeAuthenticationData): Promise<User | false> {
-        const user = await this.getUserFromIdentifier(ctx, data.username);
+        const user = await this.userService.getUserByEmailAddress(ctx, data.username);
         if (!user) {
             return false;
         }
@@ -59,13 +62,6 @@ export class NativeAuthenticationStrategy implements AuthenticationStrategy<Nati
         return user;
     }
 
-    private getUserFromIdentifier(ctx: RequestContext, identifier: string): Promise<User | undefined> {
-        return this.connection.getRepository(ctx, User).findOne({
-            where: { identifier, deletedAt: null },
-            relations: ['roles', 'roles.channels', 'authenticationMethods'],
-        });
-    }
-
     /**
      * Verify the provided password against the one we have for the given user.
      */

+ 6 - 6
packages/core/src/service/services/customer.service.ts

@@ -227,12 +227,11 @@ export class CustomerService {
                 deletedAt: null,
             },
         });
-        const existingUser = await this.connection.getRepository(ctx, User).findOne({
-            where: {
-                identifier: input.emailAddress,
-                deletedAt: null,
-            },
-        });
+        const existingUser = await this.userService.getUserByEmailAddress(
+            ctx,
+            input.emailAddress,
+            'customer',
+        );
 
         if (existingCustomer && existingUser) {
             // Customer already exists, bring to this Channel
@@ -326,6 +325,7 @@ export class CustomerService {
                     const existingUserWithEmailAddress = await this.userService.getUserByEmailAddress(
                         ctx,
                         input.emailAddress,
+                        'customer',
                     );
 
                     if (

+ 3 - 1
packages/core/src/service/services/product-option-group.service.ts

@@ -146,7 +146,9 @@ export class ProductOptionGroupService {
             };
         }
 
-        for (const option of optionGroup.options) {
+        const optionsToDelete = optionGroup.options && optionGroup.options.filter(group => !group.deletedAt);
+
+        for (const option of optionsToDelete) {
             const { result, message } = await this.productOptionService.delete(ctx, option.id);
             if (result === DeletionResult.NOT_DELETED) {
                 await this.connection.rollBackTransaction(ctx);

+ 2 - 1
packages/core/src/service/services/product-variant.service.ts

@@ -731,8 +731,9 @@ export class ProductVariantService {
         ).optionGroups;
 
         const optionIds = input.optionIds || [];
+        const activeOptions = optionGroups && optionGroups.filter(group => !group.deletedAt);
 
-        if (optionIds.length !== optionGroups.length) {
+        if (optionIds.length !== activeOptions.length) {
             this.throwIncompatibleOptionsError(optionGroups);
         }
         if (

+ 18 - 8
packages/core/src/service/services/user.service.ts

@@ -48,14 +48,24 @@ export class UserService {
         });
     }
 
-    async getUserByEmailAddress(ctx: RequestContext, emailAddress: string): Promise<User | undefined> {
-        return this.connection.getRepository(ctx, User).findOne({
-            where: {
-                identifier: emailAddress,
-                deletedAt: null,
-            },
-            relations: ['roles', 'roles.channels', 'authenticationMethods'],
-        });
+    async getUserByEmailAddress(
+        ctx: RequestContext,
+        emailAddress: string,
+        userType?: 'administrator' | 'customer',
+    ): Promise<User | undefined> {
+        const entity = userType ?? (ctx.apiType === 'admin' ? 'administrator' : 'customer');
+        const table = `${this.configService.dbConnectionOptions.entityPrefix ?? ''}${entity}`;
+
+        return this.connection
+            .getRepository(ctx, User)
+            .createQueryBuilder('user')
+            .innerJoin(table, table, `${table}.userId = user.id`)
+            .leftJoinAndSelect('user.roles', 'roles')
+            .leftJoinAndSelect('roles.channels', 'channels')
+            .leftJoinAndSelect('user.authenticationMethods', 'authenticationMethods')
+            .where('user.identifier = :identifier', { identifier: emailAddress })
+            .andWhere('user.deletedAt IS NULL')
+            .getOne();
     }
 
     /**

+ 2 - 1
packages/payments-plugin/src/mollie/mollie.plugin.ts

@@ -17,7 +17,8 @@ import { MollieService } from './mollie.service';
 export interface MolliePluginOptions {
     /**
      * @description
-     * The host of your storefront application, e.g. `'https://my-shop.com'`
+     * The host of your Vendure server, e.g. `'https://my-vendure.io'`.
+     * This is used by Mollie to send webhook events to the Vendure server
      */
     vendureHost: string;
 }

+ 0 - 9
packages/payments-plugin/src/stripe/stripe-order-process.ts

@@ -1,9 +0,0 @@
-import { CustomOrderProcess, OrderState } from '@vendure/core';
-
-const stripeOrderProcess: CustomOrderProcess<never> = {
-    async onTransitionEnd(fromState, toState, data) {
-        if (fromState === 'ArrangingPayment' && toState === 'AddingItems') {
-            data.order;
-        }
-    },
-};

+ 1 - 1
packages/payments-plugin/src/stripe/stripe.handler.ts

@@ -8,7 +8,7 @@ import {
 } from '@vendure/core';
 import Stripe from 'stripe';
 
-import { getAmountFromStripeMinorUnits, getAmountInStripeMinorUnits } from './stripe-utils';
+import { getAmountFromStripeMinorUnits } from './stripe-utils';
 import { StripeService } from './stripe.service';
 
 const { StripeError } = Stripe.errors;

+ 2 - 2
packages/payments-plugin/src/stripe/stripe.plugin.ts

@@ -17,8 +17,8 @@ import { StripePluginOptions } from './types';
  *
  * 1. You will need to create a Stripe account and get your secret key in the dashboard.
  * 2. Create a webhook endpoint in the Stripe dashboard (Developers -> Webhooks, "Add an endpoint") which listens to the `payment_intent.succeeded`
- * and `payment_intent.payment_failed` events. The URL should be `https://my-shop.com/payments/stripe`, where
- * `my-shop.com` is the host of your storefront application. *Note:* for local development, you'll need to use
+ * and `payment_intent.payment_failed` events. The URL should be `https://my-server.com/payments/stripe`, where
+ * `my-server.com` is the host of your Vendure server. *Note:* for local development, you'll need to use
  * the Stripe CLI to test your webhook locally. See the _local development_ section below.
  * 3. Get the signing secret for the newly created webhook.
  * 4. Install the Payments plugin and the Stripe Node library:

+ 0 - 1
packages/payments-plugin/src/stripe/types.ts

@@ -1,6 +1,5 @@
 import '@vendure/core/dist/entity/custom-entity-fields';
 import { Request } from 'express';
-import { IncomingMessage } from 'http';
 
 // Note: deep import is necessary here because CustomCustomerFields is also extended in the Braintree
 // plugin. Reference: https://github.com/microsoft/TypeScript/issues/46617

+ 12 - 1
packages/ui-devkit/src/compiler/scaffold.ts

@@ -1,6 +1,7 @@
 /* tslint:disable:no-console */
 import { spawn } from 'child_process';
 import * as fs from 'fs-extra';
+import glob from 'glob';
 import * as path from 'path';
 
 import {
@@ -103,7 +104,17 @@ async function copyExtensionModules(outputPath: string, extensions: AdminUiExten
 
     for (const extension of extensions) {
         const dest = path.join(outputPath, MODULES_OUTPUT_DIR, extension.id);
-        fs.copySync(extension.extensionPath, dest);
+        if (!extension.exclude) {
+            fs.copySync(extension.extensionPath, dest);
+            continue;
+        }
+
+        const exclude = extension.exclude
+            .map(e => glob.sync(path.join(extension.extensionPath, e)))
+            .flatMap(e => e);
+        fs.copySync(extension.extensionPath, dest, {
+            filter: name => name === extension.extensionPath || exclude.every(e => e !== name),
+        });
     }
 }
 

+ 12 - 0
packages/ui-devkit/src/compiler/types.ts

@@ -206,6 +206,18 @@ export interface AdminUiExtension
      * ```
      */
     pathAlias?: string;
+
+    /**
+     * @description
+     * Optional array specifying filenames or [glob](https://github.com/isaacs/node-glob) patterns that should
+     * be skipped when copying the directory defined by `extensionPath`.
+     *
+     * @example
+     * ```ts
+     * exclude: ['**\/*.spec.ts']
+     * ```
+     */
+    exclude?: string[];
 }
 
 /**

+ 13 - 9
scripts/docs/typescript-docs-parser.ts

@@ -20,6 +20,7 @@ import {
  */
 export class TypescriptDocsParser {
     private readonly atTokenPlaceholder = '__EscapedAtToken__';
+    private readonly commentBlockEndTokenPlaceholder = '__EscapedCommentBlockEndToken__'
 
     /**
      * Parses the TypeScript files given by the filePaths array and returns the
@@ -29,7 +30,7 @@ export class TypescriptDocsParser {
         const sourceFiles = filePaths.map(filePath => {
             return ts.createSourceFile(
                 filePath,
-                this.replaceEscapedAtTokens(fs.readFileSync(filePath).toString()),
+                this.replaceEscapedTokens(fs.readFileSync(filePath).toString()),
                 ts.ScriptTarget.ES2015,
                 true,
             );
@@ -288,7 +289,7 @@ export class TypescriptDocsParser {
                 const memberInfo: MemberInfo = {
                     fullText,
                     name,
-                    description: this.restoreAtTokens(description),
+                    description: this.restoreTokens(description),
                     type,
                     modifiers,
                     since,
@@ -383,7 +384,7 @@ export class TypescriptDocsParser {
             description: comment => (description += comment),
             example: comment => (description += this.formatExampleCode(comment)),
         });
-        return this.restoreAtTokens(description);
+        return this.restoreTokens(description);
     }
 
     /**
@@ -446,20 +447,23 @@ export class TypescriptDocsParser {
 
     /**
      * TypeScript from v3.5.1 interprets all '@' tokens in a tag comment as a new tag. This is a problem e.g.
-     * when a plugin includes in it's description some text like "install the @vendure/some-plugin package". Here,
+     * when a plugin includes in its description some text like "install the @vendure/some-plugin package". Here,
      * TypeScript will interpret "@vendure" as a JSDoc tag and remove it and all remaining text from the comment.
      *
      * The solution is to replace all escaped @ tokens ("\@") with a replacer string so that TypeScript treats them
      * as regular comment text, and then once it has parsed the statement, we replace them with the "@" character.
+     *
+     * Similarly, '/*' is interpreted as end of a comment block. However, it can be useful to specify a globstar
+     * pattern in descriptions and therefore it is supported as long as the leading '/' is escaped ("\/").
      */
-    private replaceEscapedAtTokens(content: string): string {
-        return content.replace(/\\@/g, this.atTokenPlaceholder);
+    private replaceEscapedTokens(content: string): string {
+        return content.replace(/\\@/g, this.atTokenPlaceholder).replace(/\\\/\*/g, this.commentBlockEndTokenPlaceholder);
     }
 
     /**
-     * Restores "@" tokens which were replaced by the replaceEscapedAtTokens() method.
+     * Restores "@" and "/*" tokens which were replaced by the replaceEscapedTokens() method.
      */
-    private restoreAtTokens(content: string): string {
-        return content.replace(new RegExp(this.atTokenPlaceholder, 'g'), '@');
+    private restoreTokens(content: string): string {
+        return content.replace(new RegExp(this.atTokenPlaceholder, 'g'), '@').replace(new RegExp(this.commentBlockEndTokenPlaceholder, 'g'), '/*');
     }
 }