Compare View
Commits (4)
-
Skins - Build/deploy support The first **_skins_** feature implementation doesn't works to `gulp build` tasks. Now, this changes enables support to a specific **`package.json`** to subthemes, allowing a default skin (optionally) in each subtheme folder. Added to some simple refactorys into `gulp/conf.js` file. Example of the new **`package.json`** file of `angular-participa-consulta` subtheme: ``` javascript { "name": "angular-participa-consulta", "version": "1.0.0", "description": "The theme for 'Consulta publica' community specific colors and UI changes", "config": { "theme": "angular-participa-consulta", "skin": "skin-yellow" //Here is the DEFAULT theme skin }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", "license": "ISC" } ``` These changes is related with merge !48 and fixes #96 See merge request !50
-
- Added support only to 'rightbar' and 'default' @TODO - Develop all layouts Signed-off-by: Paulo Tada <paulohtfs@gmail.com> Signed-off-by: Tallys Martins <tallysmartins@gmail.com>
Showing
19 changed files
Show diff stats
README.md
... | ... | @@ -53,7 +53,7 @@ See some important folders bellow: |
53 | 53 | |
54 | 54 | ## Change skin |
55 | 55 | |
56 | -- Create an any scss file into: `app/layout/skins/` | |
56 | +- Create an any scss file into: `app/layout/scss/skins/` | |
57 | 57 | > **Suggestion:** Create a `sass` file partial. Something like: **`_mycustom.scss`**. |
58 | 58 | |
59 | 59 | - Extend your skin css class from `%skin-base` scss placeholder selector. Something like this: |
... | ... | @@ -66,6 +66,14 @@ See some important folders bellow: |
66 | 66 | ``` |
67 | 67 | - Configure application to use the new theme, e.g.: |
68 | 68 | `npm config set angular-theme:skin skin-mycustom` |
69 | +OR add the default skin property to a specific `package.json` file (ONLY PERFORM A BUILD), like this: | |
70 | + | |
71 | +```json | |
72 | +"config": { | |
73 | + "skin": "skin-yellow" | |
74 | +} | |
75 | +``` | |
76 | + | |
69 | 77 | |
70 | 78 | **N.B.** |
71 | 79 | ... | ... |
gulp/build.js
... | ... | @@ -154,4 +154,8 @@ gulp.task('noosfero', ['html'], function () { |
154 | 154 | return merge(layouts, theme, index); |
155 | 155 | }); |
156 | 156 | |
157 | -gulp.task('build', ['ckeditor', 'html', 'fonts', 'other', 'locale', 'plugin-languages', 'noosfero']); | |
157 | +gulp.task('inject-skin-build', ['html'], function () { | |
158 | + gulp.start('inject-skin'); | |
159 | +}); | |
160 | + | |
161 | +gulp.task('build', ['ckeditor', 'html', 'fonts', 'other', 'locale', 'plugin-languages', 'noosfero', 'inject-skin-build']); | ... | ... |
gulp/conf.js
... | ... | @@ -9,7 +9,7 @@ |
9 | 9 | var argv = require('minimist')(process.argv.slice(2)); |
10 | 10 | var gutil = require('gulp-util'); |
11 | 11 | var path = require('path'); |
12 | -var fs = require('fs'); | |
12 | +var fs = require('fs-extra'); | |
13 | 13 | |
14 | 14 | /** |
15 | 15 | * The main paths of your project handle these with care |
... | ... | @@ -25,6 +25,17 @@ exports.paths = { |
25 | 25 | languages: 'languages' |
26 | 26 | }; |
27 | 27 | |
28 | +exports.isBuild = function () { | |
29 | + if (!exports.building) { | |
30 | + exports.building = (argv._[0] == 'build' ? true : false); | |
31 | + } | |
32 | + return exports.building; | |
33 | +}; | |
34 | + | |
35 | +exports.isDefaultTheme = function (name) { | |
36 | + return /-default$/.test(name); | |
37 | +}; | |
38 | + | |
28 | 39 | /** |
29 | 40 | * Check if theme folder exists on "themes" directory |
30 | 41 | * |
... | ... | @@ -44,14 +55,14 @@ exports.themeExists = function (path) { |
44 | 55 | * @param skin The skin name passed by arg to gulp task |
45 | 56 | */ |
46 | 57 | exports.skinExists = function (skin) { |
47 | - | |
58 | + | |
48 | 59 | var skinPath, prefixPath = ''; |
49 | 60 | var skinFile = skin+'.scss'; |
50 | 61 | if (/skin-/.test(skin)) { |
51 | 62 | skinFile = skin.replace('skin-','_')+'.scss'; |
52 | 63 | } |
53 | 64 | |
54 | - if (/-default$/.test(exports.paths.theme)) { | |
65 | + if (exports.isDefaultTheme(exports.paths.theme)) { | |
55 | 66 | prefixPath = exports.paths.src; |
56 | 67 | }else { |
57 | 68 | prefixPath = path.join(exports.paths.themes, exports.paths.theme); |
... | ... | @@ -66,7 +77,7 @@ exports.skinExists = function (skin) { |
66 | 77 | } |
67 | 78 | |
68 | 79 | var content = fs.readFileSync(skinPath, {encoding: 'utf8'}); |
69 | - if(content.search(skin) == -1) { | |
80 | + if (content.search(skin) == -1) { | |
70 | 81 | throw new Error('The skin css selector ".'+skin+'" was not found in "'+skinPath+'" file'); |
71 | 82 | }else if (content.search('@extend %skin-base') == -1) { |
72 | 83 | throw new Error('The skin css selector ".'+skin+'" needs inherit from %skin-base sass placeholder'); |
... | ... | @@ -81,15 +92,32 @@ exports.configTheme = function(theme) { |
81 | 92 | |
82 | 93 | exports.paths.allSources = [exports.paths.src, themePath]; |
83 | 94 | |
84 | - | |
85 | 95 | exports.themeExists(themePath); |
86 | 96 | exports.paths.dist = path.join("dist", exports.paths.theme); |
87 | 97 | |
88 | - if(argv.skin) { | |
98 | + if(exports.isBuild() && !exports.isDefaultTheme(exports.paths.theme)){ | |
99 | + | |
100 | + try { | |
101 | + fs.statSync(path.join(themePath,'package.json')); | |
102 | + var themeData = fs.readJsonSync(path.join(themePath,'package.json')); | |
103 | + | |
104 | + if(!themeData.config || !themeData.config.skin) { | |
105 | + throw new Error('The theme "'+exports.paths.theme+'" needs a default skin on their package.json file'); | |
106 | + } | |
107 | + argv.skin = themeData.config.skin; | |
108 | + } catch (e) { | |
109 | + gutil.log(gutil.colors.yellow('[WARNING]','The package.json file was not found into theme:'), gutil.colors.cyan(exports.paths.theme)); | |
110 | + } | |
111 | + | |
112 | + } | |
113 | + | |
114 | + if(argv.skin && argv.skin != 'skin-whbl') { | |
89 | 115 | exports.skinExists(argv.skin); |
90 | 116 | |
91 | 117 | exports.paths.skin = argv.skin; |
92 | 118 | } |
119 | + | |
120 | + gutil.log('Configuring theme', gutil.colors.green(exports.paths.theme.toUpperCase())); | |
93 | 121 | } |
94 | 122 | exports.configTheme(argv.theme); |
95 | 123 | ... | ... |
gulp/inject.js
... | ... | @@ -57,7 +57,12 @@ gulp.task('inject-skin', function () { |
57 | 57 | |
58 | 58 | if(conf.paths.skin) { |
59 | 59 | |
60 | - $.util.log('Configured theme skin:', conf.paths.skin); | |
60 | + var jsPaths = { | |
61 | + src: path.join(conf.paths.src,'./noosfero.js'), | |
62 | + dest: conf.paths.src, | |
63 | + }; | |
64 | + | |
65 | + $.util.log('Configuring theme skin:', conf.paths.skin, '...'); | |
61 | 66 | |
62 | 67 | var replaceSkin = transform(function(filename) { |
63 | 68 | return map(function(file, next) { |
... | ... | @@ -67,9 +72,14 @@ gulp.task('inject-skin', function () { |
67 | 72 | }); |
68 | 73 | }); |
69 | 74 | |
70 | - gulp.src(path.join(conf.paths.src,'./noosfero.js')) | |
75 | + if (conf.isBuild()) { | |
76 | + jsPaths.src = path.join(conf.paths.dist, 'scripts', 'app-*.js'); | |
77 | + jsPaths.dest = path.join(conf.paths.dist, 'scripts'); | |
78 | + } | |
79 | + | |
80 | + gulp.src(jsPaths.src) | |
71 | 81 | .pipe(replaceSkin) |
72 | - .pipe(gulp.dest(conf.paths.src)); | |
82 | + .pipe(gulp.dest(jsPaths.dest)); | |
73 | 83 | } |
74 | 84 | |
75 | 85 | }); | ... | ... |
package.json
... | ... | @@ -26,7 +26,8 @@ |
26 | 26 | "postinstall": "bower install; typings install; cd dev-scripts; typings install; cd ../; npm run fix-jqlite", |
27 | 27 | "start": "concurrently \"webpack -w\" \"gulp --theme=$npm_package_config_theme --skin=$npm_package_config_skin serve\"", |
28 | 28 | "generate-indexes": "ts-node --project ./dev-scripts ./dev-scripts/generate-index-modules.ts", |
29 | - "fix-jqlite": "ts-node --project ./dev-scripts dev-scripts/fix-jqlite.ts" | |
29 | + "fix-jqlite": "ts-node --project ./dev-scripts dev-scripts/fix-jqlite.ts", | |
30 | + "debug-gulp": "webpack; gulp clean; ./node_modules/.bin/iron-node node_modules/gulp/bin/gulp.js --theme=$npm_package_config_theme --skin=$npm_package_config_skin" | |
30 | 31 | }, |
31 | 32 | "devDependencies": { |
32 | 33 | "bower": "^1.7.7", |
... | ... | @@ -39,6 +40,7 @@ |
39 | 40 | "eslint-plugin-angular": "~0.12.0", |
40 | 41 | "estraverse": "~4.1.0", |
41 | 42 | "expose-loader": "^0.7.1", |
43 | + "fs-extra": "^0.30.0", | |
42 | 44 | "glob": "^7.0.0", |
43 | 45 | "gulp": "^3.9.1", |
44 | 46 | "gulp-angular-filesort": "~1.1.1", |
... | ... | @@ -69,6 +71,7 @@ |
69 | 71 | "gulp-useref": "~1.3.0", |
70 | 72 | "gulp-util": "~3.0.6", |
71 | 73 | "http-proxy-middleware": "~0.9.0", |
74 | + "iron-node": "^3.0.7", | |
72 | 75 | "istanbul": "^0.4.2", |
73 | 76 | "karma": "~0.13.10", |
74 | 77 | "karma-angular-filesort": "~1.0.0", | ... | ... |
src/app/environment/environment.html
1 | 1 | <div class="environment-container"> |
2 | 2 | <div class="row"> |
3 | - <noosfero-boxes ng-if="vm.boxes" [boxes]="vm.boxes" [owner]="vm.environment"></noosfero-boxes> | |
3 | + <noosfero-boxes ng-if="vm.boxes" | |
4 | + [layout]="vm.environment.layout_template" | |
5 | + [boxes]="vm.boxes" | |
6 | + [owner]="vm.environment"> | |
7 | + </noosfero-boxes> | |
4 | 8 | </div> |
5 | 9 | </div> | ... | ... |
src/app/layout/boxes/box.html
1 | -<div ng-class="{'col-md-2-5': box.position!=1, 'col-md-7': box.position==1}"> | |
2 | - <noosfero-block ng-repeat="block in box.blocks | orderBy: 'position'" [block]="block" [owner]="ctrl.owner"></noosfero-block> | |
1 | +<div ng-class="box.position | setBoxLayout:ctrl.layout"> | |
2 | + <noosfero-block | |
3 | + ng-repeat="block in box.blocks | orderBy: 'position'" | |
4 | + [block]="block" [owner]="ctrl.owner"> | |
5 | + </noosfero-block> | |
3 | 6 | </div> | ... | ... |
src/app/layout/boxes/boxes.component.spec.ts
... | ... | @@ -45,12 +45,7 @@ describe("Boxes Component", () => { |
45 | 45 | state.current = { name: "" }; |
46 | 46 | |
47 | 47 | it("renders boxes into a container", () => { |
48 | - expect(helper.find('div.col-md-7').length).toEqual(1); | |
49 | - expect(helper.find('div.col-md-2-5').length).toEqual(1); | |
50 | - }); | |
51 | - | |
52 | - it("check the boxes order", () => { | |
53 | - expect(helper.component.boxesOrder(properties['boxes'][0])).toEqual(1); | |
54 | - expect(helper.component.boxesOrder(properties['boxes'][1])).toEqual(0); | |
48 | + expect(helper.find('div.col-md-6').length).toEqual(1); | |
49 | + expect(helper.find('div.col-md-3').length).toEqual(1); | |
55 | 50 | }); |
56 | 51 | }); | ... | ... |
src/app/layout/boxes/boxes.component.ts
1 | 1 | import {Input, Component} from 'ng-forward'; |
2 | +import {DisplayBoxes} from "./display-boxes.filter"; | |
3 | +import {SetBoxLayout} from "./set-box-layout.filter"; | |
2 | 4 | |
3 | 5 | @Component({ |
4 | 6 | selector: "noosfero-boxes", |
5 | - templateUrl: "app/layout/boxes/boxes.html" | |
7 | + templateUrl: "app/layout/boxes/boxes.html", | |
8 | + directives: [DisplayBoxes, SetBoxLayout] | |
6 | 9 | }) |
7 | 10 | export class BoxesComponent { |
8 | 11 | |
9 | 12 | @Input() boxes: noosfero.Box[]; |
10 | 13 | @Input() owner: noosfero.Profile | noosfero.Environment; |
14 | + @Input() layout: string; | |
11 | 15 | |
12 | - boxesOrder(box: noosfero.Box) { | |
13 | - if (box.position === 2) return 0; | |
14 | - return box.position; | |
15 | - } | |
16 | 16 | } | ... | ... |
src/app/layout/boxes/boxes.html
... | ... | @@ -0,0 +1,51 @@ |
1 | +import {DisplayBoxes} from './display-boxes.filter'; | |
2 | + | |
3 | +describe("Boxes Filters", () => { | |
4 | + describe("Display Boxes Filter", () => { | |
5 | + | |
6 | + let boxes: noosfero.Box[] = [ | |
7 | + {id: 1, position: 1 }, | |
8 | + {id: 2, position: 2 }, | |
9 | + {id: 3, position: 3 }, | |
10 | + {id: 4, position: 4 } | |
11 | + ]; | |
12 | + | |
13 | + let expected_on_default: noosfero.Box[] = [ | |
14 | + {id: 1, position: 1 }, | |
15 | + {id: 2, position: 2 }, | |
16 | + {id: 3, position: 3 }, | |
17 | + ]; | |
18 | + | |
19 | + let expected_on_rightbar: noosfero.Box[] = [ | |
20 | + {id: 1, position: 1 }, | |
21 | + {id: 3, position: 3 }, | |
22 | + ]; | |
23 | + | |
24 | + it("filter boxes when layout is set to default", done => { | |
25 | + let filter = new DisplayBoxes(); | |
26 | + | |
27 | + let filtered_boxes: noosfero.Box[] = filter.transform(boxes, "default"); | |
28 | + expect(filtered_boxes.length).toEqual(3); | |
29 | + expect(filtered_boxes).toEqual(expected_on_default); | |
30 | + done(); | |
31 | + }); | |
32 | + | |
33 | + it("filter boxes when layout is set to rightbar", done => { | |
34 | + let filter = new DisplayBoxes(); | |
35 | + | |
36 | + let filtered_boxes: noosfero.Box[] = filter.transform(boxes, "rightbar"); | |
37 | + expect(filtered_boxes.length).toEqual(2); | |
38 | + expect(filtered_boxes).toEqual(expected_on_rightbar); | |
39 | + done(); | |
40 | + }); | |
41 | + | |
42 | + it("filter boxes with default layout when invalid layout is given", done => { | |
43 | + let filter = new DisplayBoxes(); | |
44 | + | |
45 | + let filtered_boxes: noosfero.Box[] = filter.transform(boxes, ""); | |
46 | + expect(filtered_boxes.length).toEqual(3); | |
47 | + expect(filtered_boxes).toEqual(expected_on_default); | |
48 | + done(); | |
49 | + }); | |
50 | + }); | |
51 | +}); | ... | ... |
... | ... | @@ -0,0 +1,35 @@ |
1 | +import {Pipe, Inject} from "ng-forward"; | |
2 | + | |
3 | +@Pipe("displayBoxes") | |
4 | +export class DisplayBoxes { | |
5 | + | |
6 | + transform(boxes: noosfero.Box[], layout: string) { | |
7 | + let function_str: string = "visible_on" + layout; | |
8 | + let valid_boxes: number[] = []; | |
9 | + let selected: noosfero.Box[] = []; | |
10 | + boxes = boxes || []; | |
11 | + | |
12 | + if (layout === "rightbar") { | |
13 | + valid_boxes = this.visible_on_right_bar(); | |
14 | + }else { | |
15 | + valid_boxes = this.visible_on_default(); | |
16 | + } | |
17 | + | |
18 | + for (let box of boxes) { | |
19 | + if (valid_boxes.indexOf(box.position) !== -1) { | |
20 | + selected.push(box); | |
21 | + } | |
22 | + } | |
23 | + return selected; | |
24 | + } | |
25 | + | |
26 | + private visible_on_default() { | |
27 | + return [1, 2, 3]; | |
28 | + } | |
29 | + | |
30 | + private visible_on_right_bar() { | |
31 | + return [1, 3]; | |
32 | + } | |
33 | + | |
34 | +} | |
35 | + | ... | ... |
... | ... | @@ -0,0 +1,34 @@ |
1 | +import {SetBoxLayout} from './set-box-layout.filter'; | |
2 | + | |
3 | +describe("Boxes Filters", () => { | |
4 | + describe("Set Box Layout Filter", () => { | |
5 | + let box_layout_filter = new SetBoxLayout(); | |
6 | + describe("When layout is set to default", () => { | |
7 | + it("return style when box position is 1 ", done => { | |
8 | + expect(box_layout_filter.transform(1, "default")).toEqual("col-md-6 col-md-push-3"); | |
9 | + done(); | |
10 | + }); | |
11 | + it("return style when box position is 2", done => { | |
12 | + expect(box_layout_filter.transform(2, "default")).toEqual("col-md-3 col-md-pull-6"); | |
13 | + done(); | |
14 | + }); | |
15 | + it("return style when any other position is given", done => { | |
16 | + expect(box_layout_filter.transform(null, "default")).toEqual("col-md-3"); | |
17 | + expect(box_layout_filter.transform(3, "default")).toEqual("col-md-3"); | |
18 | + expect(box_layout_filter.transform(99, "default")).toEqual("col-md-3"); | |
19 | + done(); | |
20 | + }); | |
21 | + }); | |
22 | + | |
23 | + describe("When layout is set to right_bar", () => { | |
24 | + it("return style when box position is 1 ", done => { | |
25 | + expect(box_layout_filter.transform(1, "rightbar")).toEqual("col-sm-12 col-md-8"); | |
26 | + done(); | |
27 | + }); | |
28 | + it("return style when box other position is given", done => { | |
29 | + expect(box_layout_filter.transform(2, "rightbar")).toEqual("col-sm-12 col-md-4"); | |
30 | + done(); | |
31 | + }); | |
32 | + }); | |
33 | + }); | |
34 | +}); | ... | ... |
... | ... | @@ -0,0 +1,32 @@ |
1 | +import {Pipe, Inject} from "ng-forward"; | |
2 | + | |
3 | +@Pipe("setBoxLayout") | |
4 | +export class SetBoxLayout { | |
5 | + | |
6 | + transform(pos: number, layout: string) { | |
7 | + if (layout === "rightbar") { | |
8 | + return this.right_bar(pos); | |
9 | + }else { | |
10 | + return this.default(pos); | |
11 | + } | |
12 | + } | |
13 | + | |
14 | + private default(position: number) { | |
15 | + if (position === 1) { | |
16 | + return "col-md-6 col-md-push-3"; | |
17 | + }else if (position === 2) { | |
18 | + return "col-md-3 col-md-pull-6"; | |
19 | + }else { | |
20 | + return "col-md-3"; | |
21 | + } | |
22 | + } | |
23 | + | |
24 | + private right_bar(position: number) { | |
25 | + if (position === 1) { | |
26 | + return "col-sm-12 col-md-8"; | |
27 | + }else { | |
28 | + return "col-sm-12 col-md-4"; | |
29 | + } | |
30 | + } | |
31 | + | |
32 | +} | ... | ... |
src/app/profile/profile.html
1 | 1 | <div class="profile-container"> |
2 | - <custom-content class="profile-header" [label]="'profile.custom_header.label'" [attribute]="'custom_header'" [profile]="vm.profile"></custom-content> | |
3 | - <div class="row"> | |
4 | - <noosfero-boxes ng-if="vm.boxes" [boxes]="vm.boxes" [owner]="vm.profile"></noosfero-boxes> | |
5 | - </div> | |
2 | + <custom-content class="profile-header" | |
3 | + [label]="'profile.custom_header.label'" | |
4 | + [attribute]="'custom_header'" | |
5 | + [profile]="vm.profile"> | |
6 | + </custom-content> | |
7 | + <noosfero-boxes ng-if="vm.boxes" | |
8 | + [layout]="vm.profile.layout_template" | |
9 | + [boxes]="vm.boxes" | |
10 | + [owner]="vm.profile" class="row"> | |
11 | + </noosfero-boxes> | |
6 | 12 | <custom-content class="profile-footer" [label]="'profile.custom_footer.label'" [attribute]="'custom_footer'" [profile]="vm.profile"></custom-content> |
7 | 13 | </div> | ... | ... |
src/lib/ng-noosfero-api/interfaces/environment.ts
... | ... | @@ -15,5 +15,14 @@ namespace noosfero { |
15 | 15 | */ |
16 | 16 | id: number; |
17 | 17 | settings: any |
18 | + | |
19 | + /** | |
20 | + * @ngdoc property | |
21 | + * @name layout_template | |
22 | + * @propertyOf noofero.Environment | |
23 | + * @returns {string} The Environment layout (e.g. default, rightbar) | |
24 | + */ | |
25 | + layout_template: string; | |
18 | 26 | } |
19 | -} | |
20 | 27 | \ No newline at end of file |
28 | +} | |
29 | + | ... | ... |
src/lib/ng-noosfero-api/interfaces/profile.ts
... | ... | @@ -80,5 +80,13 @@ namespace noosfero { |
80 | 80 | custom_footer: string; |
81 | 81 | |
82 | 82 | permissions: string[]; |
83 | + | |
84 | + /** | |
85 | + * @ngdoc property | |
86 | + * @name layout_template | |
87 | + * @propertyOf noofero.Profile | |
88 | + * @returns {string} The Profile layout template (e.g.: "rightbar", "default") | |
89 | + */ | |
90 | + layout_template: string; | |
83 | 91 | } |
84 | 92 | } | ... | ... |
themes/angular-participa-consulta/README.md
... | ... | @@ -3,8 +3,25 @@ |
3 | 3 | |
4 | 4 | ## Getting started |
5 | 5 | |
6 | +**1. To use with `npm start` command** | |
7 | +> **PS:** To developer mode | |
8 | + | |
6 | 9 | Run these commands to set the proper theme and skin |
7 | 10 | |
8 | 11 | `npm config set angular-theme:theme angular-participa-consulta` |
9 | 12 | |
10 | 13 | `npm config set angular-theme:skin skin-yellow` |
14 | + | |
15 | +**2. To generate a build** | |
16 | +> **PS:** Deploy to production | |
17 | + | |
18 | +* Create a specific `package.json` file into this directory | |
19 | +> Use the **`npm init`** command to create this file | |
20 | + | |
21 | +* Configure the **main** skin to this theme. Add the property `config['skin']` below: | |
22 | + | |
23 | +```json | |
24 | +"config": { | |
25 | + "skin": "skin-yellow" | |
26 | +} | |
27 | +``` | ... | ... |
... | ... | @@ -0,0 +1,14 @@ |
1 | +{ | |
2 | + "name": "angular-participa-consulta", | |
3 | + "version": "1.0.0", | |
4 | + "description": "The theme for 'Consulta publica' community specific colors and UI changes", | |
5 | + "config": { | |
6 | + "theme": "angular-participa-consulta", | |
7 | + "skin": "skin-yellow" | |
8 | + }, | |
9 | + "scripts": { | |
10 | + "test": "echo \"Error: no test specified\" && exit 1" | |
11 | + }, | |
12 | + "author": "", | |
13 | + "license": "ISC" | |
14 | +} | ... | ... |