From ec05c814ee0ee444479e22ae767109cace18cb0b Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Mon, 27 Jan 2025 09:54:52 +0000 Subject: [PATCH] fix(@angular/ssr): rename `provideServerRoutesConfig` to `provideServerRouting` This commit renames `provideServerRoutesConfig` to `provideServerRouting` and updates the second parameter to use the `ServerRoutes` features. This change improves alignment with the framework's API conventions and the way features are integrated. ### Example Usage: Before: ```typescript provideServerRoutesConfig(serverRoutes, { appShellRoute: 'shell' }) ``` After: ```typescript provideServerRouting(serverRoutes, withAppShell(AppShellComponent)) ``` --- goldens/public-api/angular/ssr/index.api.md | 13 +- packages/angular/ssr/public_api.ts | 2 + .../angular/ssr/src/routes/route-config.ts | 135 ++++++++++++++++-- packages/angular/ssr/test/testing-utils.ts | 4 +- .../schematics/angular/app-shell/index.ts | 135 +++++++----------- .../angular/app-shell/index_spec.ts | 101 +------------ .../app/app.module.server.ts.template | 4 +- .../app/app.config.server.ts.template | 4 +- 8 files changed, 203 insertions(+), 195 deletions(-) diff --git a/goldens/public-api/angular/ssr/index.api.md b/goldens/public-api/angular/ssr/index.api.md index 57487bea54..7811c54500 100644 --- a/goldens/public-api/angular/ssr/index.api.md +++ b/goldens/public-api/angular/ssr/index.api.md @@ -4,7 +4,10 @@ ```ts +import { DefaultExport } from '@angular/router'; import { EnvironmentProviders } from '@angular/core'; +import { Provider } from '@angular/core'; +import { Type } from '@angular/core'; // @public export class AngularAppEngine { @@ -23,9 +26,12 @@ export enum PrerenderFallback { Server = 0 } -// @public +// @public @deprecated export function provideServerRoutesConfig(routes: ServerRoute[], options?: ServerRoutesConfigOptions): EnvironmentProviders; +// @public +export function provideServerRouting(routes: ServerRoute[], ...features: ServerRoutesFeature[]): EnvironmentProviders; + // @public export enum RenderMode { Client = 1, @@ -63,7 +69,7 @@ export interface ServerRoutePrerenderWithParams extends Omit Promise[]>; } -// @public +// @public @deprecated export interface ServerRoutesConfigOptions { appShellRoute?: string; } @@ -73,6 +79,9 @@ export interface ServerRouteServer extends ServerRouteCommon { renderMode: RenderMode.Server; } +// @public +export function withAppShell(component: Type | (() => Promise | DefaultExport>>)): ServerRoutesFeature; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/angular/ssr/public_api.ts b/packages/angular/ssr/public_api.ts index 8f62f438ea..5b14de8e34 100644 --- a/packages/angular/ssr/public_api.ts +++ b/packages/angular/ssr/public_api.ts @@ -16,6 +16,8 @@ export { type ServerRoute, type ServerRoutesConfigOptions, provideServerRoutesConfig, + provideServerRouting, + withAppShell, RenderMode, type ServerRouteClient, type ServerRoutePrerender, diff --git a/packages/angular/ssr/src/routes/route-config.ts b/packages/angular/ssr/src/routes/route-config.ts index b0b85fffbe..d72602a6d9 100644 --- a/packages/angular/ssr/src/routes/route-config.ts +++ b/packages/angular/ssr/src/routes/route-config.ts @@ -6,11 +6,43 @@ * found in the LICENSE file at https://angular.dev/license */ -import { EnvironmentProviders, InjectionToken, makeEnvironmentProviders } from '@angular/core'; +import { + EnvironmentProviders, + InjectionToken, + Provider, + Type, + makeEnvironmentProviders, +} from '@angular/core'; +import { type DefaultExport, ROUTES, type Route } from '@angular/router'; + +/** + * The internal path used for the app shell route. + * @internal + */ +const APP_SHELL_ROUTE = 'ng-app-shell'; + +/** + * Identifies a particular kind of `ServerRoutesFeatureKind`. + * @see {@link ServerRoutesFeature} + * @developerPreview + */ +enum ServerRoutesFeatureKind { + AppShell, +} + +/** + * Helper type to represent a server routes feature. + * @see {@link ServerRoutesFeatureKind} + * @developerPreview + */ +interface ServerRoutesFeature { + ɵkind: FeatureKind; + ɵproviders: Provider[]; +} /** * Different rendering modes for server routes. - * @see {@link provideServerRoutesConfig} + * @see {@link provideServerRouting} * @see {@link ServerRoute} * @developerPreview */ @@ -148,7 +180,7 @@ export interface ServerRouteServer extends ServerRouteCommon { /** * Server route configuration. - * @see {@link provideServerRoutesConfig} + * @see {@link provideServerRouting} * @developerPreview */ export type ServerRoute = @@ -163,8 +195,9 @@ export type ServerRoute = * This interface defines the optional settings available for configuring server routes * in the server-side environment, such as specifying a path to the app shell route. * - * @see {@link provideServerRoutesConfig} - * @developerPreview + * + * @see {@link provideServerRouting} + * @deprecated use `provideServerRouting`. This will be removed in version 20. */ export interface ServerRoutesConfigOptions { @@ -172,8 +205,6 @@ export interface ServerRoutesConfigOptions { * Defines the route to be used as the app shell, which serves as the main entry * point for the application. This route is often used to enable server-side rendering * of the application shell for requests that do not match any specific server route. - * - * @see {@link https://angular.dev/ecosystem/service-workers/app-shell | App shell pattern on Angular.dev} */ appShellRoute?: string; } @@ -182,7 +213,13 @@ export interface ServerRoutesConfigOptions { * Configuration value for server routes configuration. * @internal */ -export interface ServerRoutesConfig extends ServerRoutesConfigOptions { +export interface ServerRoutesConfig { + /** + * Defines the route to be used as the app shell. + */ + appShellRoute?: string; + + /** List of server routes for the application. */ routes: ServerRoute[]; } @@ -204,6 +241,8 @@ export const SERVER_ROUTES_CONFIG = new InjectionToken('SERV * * @see {@link ServerRoute} * @see {@link ServerRoutesConfigOptions} + * @see {@link provideServerRouting} + * @deprecated use `provideServerRouting`. This will be removed in version 20. * @developerPreview */ export function provideServerRoutesConfig( @@ -223,3 +262,83 @@ export function provideServerRoutesConfig( }, ]); } + +/** + * Sets up the necessary providers for configuring server routes. + * This function accepts an array of server routes and optional configuration + * options, returning an `EnvironmentProviders` object that encapsulates + * the server routes and configuration settings. + * + * @param routes - An array of server routes to be provided. + * @param features - (Optional) server routes features. + * @returns An `EnvironmentProviders` instance with the server routes configuration. + * + * @see {@link ServerRoute} + * @see {@link withAppShell} + * @developerPreview + */ +export function provideServerRouting( + routes: ServerRoute[], + ...features: ServerRoutesFeature[] +): EnvironmentProviders { + const config: ServerRoutesConfig = { routes }; + const hasAppShell = features.some((f) => f.ɵkind === ServerRoutesFeatureKind.AppShell); + if (hasAppShell) { + config.appShellRoute = APP_SHELL_ROUTE; + } + + const providers: Provider[] = [ + { + provide: SERVER_ROUTES_CONFIG, + useValue: config, + }, + ]; + + for (const feature of features) { + providers.push(...feature.ɵproviders); + } + + return makeEnvironmentProviders(providers); +} + +/** + * Configures the app shell route with the provided component. + * + * The app shell serves as the main entry point for the application and is commonly used + * to enable server-side rendering (SSR) of the application shell. It handles requests + * that do not match any specific server route, providing a fallback mechanism and improving + * perceived performance during navigation. + * + * This configuration is particularly useful in applications leveraging Progressive Web App (PWA) + * patterns, such as service workers, to deliver a seamless user experience. + * + * @param component The Angular component to render for the app shell route. + * @returns A server routes feature configuration for the app shell. + * + * @see {@link provideServerRouting} + * @see {@link https://angular.dev/ecosystem/service-workers/app-shell | App shell pattern on Angular.dev} + */ +export function withAppShell( + component: Type | (() => Promise | DefaultExport>>), +): ServerRoutesFeature { + const routeConfig: Route = { + path: APP_SHELL_ROUTE, + }; + + if ('ɵcmp' in component) { + routeConfig.component = component as Type; + } else { + routeConfig.loadComponent = component as () => Promise>; + } + + return { + ɵkind: ServerRoutesFeatureKind.AppShell, + ɵproviders: [ + { + provide: ROUTES, + useValue: routeConfig, + multi: true, + }, + ], + }; +} diff --git a/packages/angular/ssr/test/testing-utils.ts b/packages/angular/ssr/test/testing-utils.ts index fdfad95983..b6d01398d7 100644 --- a/packages/angular/ssr/test/testing-utils.ts +++ b/packages/angular/ssr/test/testing-utils.ts @@ -18,7 +18,7 @@ import { provideServerRendering } from '@angular/platform-server'; import { RouterOutlet, Routes, provideRouter } from '@angular/router'; import { destroyAngularServerApp } from '../src/app'; import { ServerAsset, setAngularAppManifest } from '../src/manifest'; -import { ServerRoute, provideServerRoutesConfig } from '../src/routes/route-config'; +import { ServerRoute, provideServerRouting } from '../src/routes/route-config'; @Component({ standalone: true, @@ -97,7 +97,7 @@ export function setAngularAppTestingManifest( provideServerRendering(), provideExperimentalZonelessChangeDetection(), provideRouter(routes), - provideServerRoutesConfig(serverRoutes), + provideServerRouting(serverRoutes), ...extraProviders, ], }); diff --git a/packages/schematics/angular/app-shell/index.ts b/packages/schematics/angular/app-shell/index.ts index 46adb35f05..725f6d1267 100644 --- a/packages/schematics/angular/app-shell/index.ts +++ b/packages/schematics/angular/app-shell/index.ts @@ -17,7 +17,6 @@ import { import { dirname, join } from 'node:path/posix'; import ts from '../third_party/github.com/Microsoft/TypeScript/lib/typescript'; import { - addImportToModule, addSymbolToNgModuleMetadata, findNode, findNodes, @@ -140,19 +139,6 @@ function validateProject(mainPath: string): Rule { }; } -function addRouterModule(mainPath: string): Rule { - return (host: Tree) => { - const modulePath = getAppModulePath(host, mainPath); - const moduleSource = getSourceFile(host, modulePath); - const changes = addImportToModule(moduleSource, modulePath, 'RouterModule', '@angular/router'); - const recorder = host.beginUpdate(modulePath); - applyToUpdateRecorder(recorder, changes); - host.commitUpdate(recorder); - - return host; - }; -} - function getMetadataProperty(metadata: ts.Node, propertyName: string): ts.PropertyAssignment { const properties = (metadata as ts.ObjectLiteralExpression).properties; const property = properties.filter(ts.isPropertyAssignment).filter((prop) => { @@ -265,7 +251,7 @@ function addStandaloneServerRoute(options: AppShellOptions): Rule { throw new SchematicsException(`Cannot find "${configFilePath}".`); } - let recorder = host.beginUpdate(configFilePath); + const recorder = host.beginUpdate(configFilePath); let configSourceFile = getSourceFile(host, configFilePath); if (!isImported(configSourceFile, 'ROUTES', '@angular/router')) { const routesChange = insertImport( @@ -295,49 +281,30 @@ function addStandaloneServerRoute(options: AppShellOptions): Rule { const updatedProvidersString = [ ...providersLiteral.elements.map((element) => ' ' + element.getText()), ` { - provide: ROUTES, - multi: true, - useValue: [{ - path: '${APP_SHELL_ROUTE}', - component: AppShellComponent - }] - }\n `, + provide: ROUTES, + multi: true, + useValue: [{ + path: '${APP_SHELL_ROUTE}', + component: AppShellComponent + }] + }\n `, ]; recorder.insertRight(providersLiteral.getStart(), `[\n${updatedProvidersString.join(',\n')}]`); - if (options.serverRouting) { - host.commitUpdate(recorder); - configSourceFile = getSourceFile(host, configFilePath); - const functionCall = findNodes(configSourceFile, ts.isCallExpression).find( - (n) => - ts.isIdentifier(n.expression) && n.expression.getText() === 'provideServerRoutesConfig', - ); - - if (!functionCall) { - throw new SchematicsException( - `Cannot find the "provideServerRoutesConfig" function call in "${configFilePath}".`, - ); - } - - recorder = host.beginUpdate(configFilePath); - recorder.insertLeft(functionCall.end - 1, `, { appShellRoute: '${APP_SHELL_ROUTE}' }`); - } - - // Add AppShellComponent import - const appShellImportChange = insertImport( - configSourceFile, - configFilePath, - 'AppShellComponent', - './app-shell/app-shell.component', - ); - - applyToUpdateRecorder(recorder, [appShellImportChange]); + applyToUpdateRecorder(recorder, [ + insertImport( + configSourceFile, + configFilePath, + 'AppShellComponent', + './app-shell/app-shell.component', + ), + ]); host.commitUpdate(recorder); }; } -function addServerRoutingConfig(options: AppShellOptions): Rule { +function addServerRoutingConfig(options: AppShellOptions, isStandalone: boolean): Rule { return async (host: Tree) => { const workspace = await getWorkspace(host); const project = workspace.projects.get(options.project); @@ -345,39 +312,43 @@ function addServerRoutingConfig(options: AppShellOptions): Rule { throw new SchematicsException(`Project name "${options.project}" doesn't not exist.`); } - const configFilePath = join(project.sourceRoot ?? 'src', 'app/app.routes.server.ts'); - if (!host.exists(configFilePath)) { + const configFilePath = isStandalone + ? join(project.sourceRoot ?? 'src', 'app/app.config.server.ts') + : getServerModulePath(host, project.sourceRoot || 'src', 'main.server.ts'); + + if (!configFilePath || !host.exists(configFilePath)) { throw new SchematicsException(`Cannot find "${configFilePath}".`); } - const sourceFile = getSourceFile(host, configFilePath); - const nodes = getSourceNodes(sourceFile); + let recorder = host.beginUpdate(configFilePath); + const configSourceFile = getSourceFile(host, configFilePath); + const functionCall = findNodes( + configSourceFile, + ts.isCallExpression, + /** max */ undefined, + /** recursive */ true, + ).find( + (n) => ts.isIdentifier(n.expression) && n.expression.getText() === 'provideServerRouting', + ); - // Find the serverRoutes variable declaration - const serverRoutesNode = nodes.find( - (node) => - ts.isVariableDeclaration(node) && - node.initializer && - ts.isArrayLiteralExpression(node.initializer) && - node.type && - ts.isArrayTypeNode(node.type) && - node.type.getText().includes('ServerRoute'), - ) as ts.VariableDeclaration | undefined; - - if (!serverRoutesNode) { + if (!functionCall) { throw new SchematicsException( - `Cannot find the "ServerRoute" configuration in "${configFilePath}".`, + `Cannot find the "provideServerRouting" function call in "${configFilePath}".`, ); } - const recorder = host.beginUpdate(configFilePath); - const arrayLiteral = serverRoutesNode.initializer as ts.ArrayLiteralExpression; - const firstElementPosition = - arrayLiteral.elements[0]?.getStart() ?? arrayLiteral.getStart() + 1; - const newRouteString = `{ - path: '${APP_SHELL_ROUTE}', - renderMode: RenderMode.AppShell - },\n`; - recorder.insertLeft(firstElementPosition, newRouteString); + + recorder = host.beginUpdate(configFilePath); + recorder.insertLeft(functionCall.end - 1, `, withAppShell(AppShellComponent)`); + + applyToUpdateRecorder(recorder, [ + insertImport(configSourceFile, configFilePath, 'withAppShell', '@angular/ssr'), + insertImport( + configSourceFile, + configFilePath, + 'AppShellComponent', + './app-shell/app-shell.component', + ), + ]); host.commitUpdate(recorder); }; @@ -391,10 +362,14 @@ export default function (options: AppShellOptions): Rule { return chain([ validateProject(browserEntryPoint), schematic('server', options), - ...(isStandalone - ? [addStandaloneServerRoute(options)] - : [addRouterModule(browserEntryPoint), addServerRoutes(options)]), - options.serverRouting ? noop() : addAppShellConfigToWorkspace(options), + ...(options.serverRouting + ? [noop()] + : isStandalone + ? [addStandaloneServerRoute(options)] + : [addServerRoutes(options)]), + options.serverRouting + ? addServerRoutingConfig(options, isStandalone) + : addAppShellConfigToWorkspace(options), schematic('component', { name: 'app-shell', module: 'app.module.server.ts', diff --git a/packages/schematics/angular/app-shell/index_spec.ts b/packages/schematics/angular/app-shell/index_spec.ts index 4381b05efd..09fbe4ba6e 100644 --- a/packages/schematics/angular/app-shell/index_spec.ts +++ b/packages/schematics/angular/app-shell/index_spec.ts @@ -69,13 +69,6 @@ describe('App Shell Schematic', () => { expect(tree.exists(filePath)).toEqual(true); }); - it('should add router module to client app module', async () => { - const tree = await schematicRunner.runSchematic('app-shell', defaultOptions, appTree); - const filePath = '/projects/bar/src/app/app.module.ts'; - const content = tree.readContent(filePath); - expect(content).toMatch(/import { RouterModule } from '@angular\/router';/); - }); - it('should not fail when AppModule have imported RouterModule already', async () => { const updateRecorder = appTree.beginUpdate('/projects/bar/src/app/app.module.ts'); updateRecorder.insertLeft(0, "import { RouterModule } from '@angular/router';"); @@ -87,81 +80,12 @@ describe('App Shell Schematic', () => { expect(content).toMatch(/import { RouterModule } from '@angular\/router';/); }); - describe('Add router-outlet', () => { - function makeInlineTemplate(tree: UnitTestTree, template?: string): void { - template = - template || - ` -

- App works! -

`; - const newText = ` - import { Component } from '@angular/core'; - - @Component({ - selector: '' - template: \` - ${template} - \`, - styleUrls: ['./app.component.css'] - }) - export class AppComponent { } - - `; - tree.overwrite('/projects/bar/src/app/app.component.ts', newText); - tree.delete('/projects/bar/src/app/app.component.html'); - } - - it('should not re-add the router outlet (external template)', async () => { - const htmlPath = '/projects/bar/src/app/app.component.html'; - appTree.overwrite(htmlPath, ''); - const tree = await schematicRunner.runSchematic('app-shell', defaultOptions, appTree); - const content = tree.readContent(htmlPath); - const matches = content.match(/<\/router-outlet>/g); - const numMatches = matches ? matches.length : 0; - expect(numMatches).toEqual(1); - }); - - it('should not re-add the router outlet (inline template)', async () => { - makeInlineTemplate(appTree, ''); - const tree = await schematicRunner.runSchematic('app-shell', defaultOptions, appTree); - const content = tree.readContent('/projects/bar/src/app/app.component.ts'); - const matches = content.match(/<\/router-outlet>/g); - const numMatches = matches ? matches.length : 0; - expect(numMatches).toEqual(1); - }); - }); - - it('should add router imports to server module', async () => { - const tree = await schematicRunner.runSchematic('app-shell', defaultOptions, appTree); - const filePath = '/projects/bar/src/app/app.module.server.ts'; - const content = tree.readContent(filePath); - expect(content).toMatch(/import { Routes, RouterModule } from '@angular\/router';/); - }); - it('should work if server config was added prior to running the app-shell schematic', async () => { let tree = await schematicRunner.runSchematic('server', defaultOptions, appTree); tree = await schematicRunner.runSchematic('app-shell', defaultOptions, tree); expect(tree.exists('/projects/bar/src/app/app-shell/app-shell.component.ts')).toBe(true); }); - it('should define a server route', async () => { - const tree = await schematicRunner.runSchematic('app-shell', defaultOptions, appTree); - const filePath = '/projects/bar/src/app/app.module.server.ts'; - const content = tree.readContent(filePath); - expect(content).toMatch(/const routes: Routes = \[/); - }); - - it('should import RouterModule with forRoot', async () => { - const tree = await schematicRunner.runSchematic('app-shell', defaultOptions, appTree); - const filePath = '/projects/bar/src/app/app.module.server.ts'; - const content = tree.readContent(filePath); - expect(content).toMatch( - /const routes: Routes = \[ { path: 'shell', component: AppShellComponent }\];/, - ); - expect(content).toContain(`ServerModule, RouterModule.forRoot(routes)]`); - }); - it('should create the shell component', async () => { const tree = await schematicRunner.runSchematic('app-shell', defaultOptions, appTree); expect(tree.exists('/projects/bar/src/app/app-shell/app-shell.component.ts')).toBe(true); @@ -200,35 +124,14 @@ describe('App Shell Schematic', () => { expect(content).toMatch(/app-shell\.component/); }); - it(`should update the 'provideServerRoutesConfig' call to include 'appShellRoute`, async () => { + it(`should update the 'provideServerRouting' call to include 'withAppShell'`, async () => { const tree = await schematicRunner.runSchematic('app-shell', defaultOptions, appTree); const content = tree.readContent('/projects/bar/src/app/app.config.server.ts'); expect(tags.oneLine`${content}`).toContain( - tags.oneLine`provideServerRoutesConfig(serverRoutes, { appShellRoute: 'shell' })`, + tags.oneLine`provideServerRouting(serverRoutes, withAppShell(AppShellComponent))`, ); }); - it('should define a server route', async () => { - const tree = await schematicRunner.runSchematic('app-shell', defaultOptions, appTree); - const filePath = '/projects/bar/src/app/app.config.server.ts'; - const content = tree.readContent(filePath); - expect(tags.oneLine`${content}`).toContain(tags.oneLine`{ - provide: ROUTES, - multi: true, - useValue: [{ - path: 'shell', - component: AppShellComponent - }] - }`); - }); - - it(`should add import to 'ROUTES' token from '@angular/router'`, async () => { - const tree = await schematicRunner.runSchematic('app-shell', defaultOptions, appTree); - const filePath = '/projects/bar/src/app/app.config.server.ts'; - const content = tree.readContent(filePath); - expect(content).toContain(`import { ROUTES } from '@angular/router';`); - }); - it(`should add import to 'AppShellComponent'`, async () => { const tree = await schematicRunner.runSchematic('app-shell', defaultOptions, appTree); const filePath = '/projects/bar/src/app/app.config.server.ts'; diff --git a/packages/schematics/angular/server/files/application-builder/ngmodule-src/app/app.module.server.ts.template b/packages/schematics/angular/server/files/application-builder/ngmodule-src/app/app.module.server.ts.template index 23b7b3bbd4..107232f910 100644 --- a/packages/schematics/angular/server/files/application-builder/ngmodule-src/app/app.module.server.ts.template +++ b/packages/schematics/angular/server/files/application-builder/ngmodule-src/app/app.module.server.ts.template @@ -1,13 +1,13 @@ import { NgModule } from '@angular/core'; import { ServerModule } from '@angular/platform-server';<% if(serverRouting) { %> -import { provideServerRoutesConfig } from '@angular/ssr';<% } %> +import { provideServerRouting } from '@angular/ssr';<% } %> import { AppComponent } from './app.component'; import { AppModule } from './app.module';<% if(serverRouting) { %> import { serverRoutes } from './app.routes.server';<% } %> @NgModule({ imports: [AppModule, ServerModule],<% if(serverRouting) { %> - providers: [provideServerRoutesConfig(serverRoutes)],<% } %> + providers: [provideServerRouting(serverRoutes)],<% } %> bootstrap: [AppComponent], }) export class AppServerModule {} diff --git a/packages/schematics/angular/server/files/application-builder/standalone-src/app/app.config.server.ts.template b/packages/schematics/angular/server/files/application-builder/standalone-src/app/app.config.server.ts.template index 2ee1daaa53..a7fd3d0b4f 100644 --- a/packages/schematics/angular/server/files/application-builder/standalone-src/app/app.config.server.ts.template +++ b/packages/schematics/angular/server/files/application-builder/standalone-src/app/app.config.server.ts.template @@ -1,13 +1,13 @@ import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; import { provideServerRendering } from '@angular/platform-server';<% if(serverRouting) { %> -import { provideServerRoutesConfig } from '@angular/ssr';<% } %> +import { provideServerRouting } from '@angular/ssr';<% } %> import { appConfig } from './app.config';<% if(serverRouting) { %> import { serverRoutes } from './app.routes.server';<% } %> const serverConfig: ApplicationConfig = { providers: [ provideServerRendering(),<% if(serverRouting) { %> - provideServerRoutesConfig(serverRoutes)<% } %> + provideServerRouting(serverRoutes)<% } %> ] };