@ui5/builder 3.0.9 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,10 +2,23 @@
2
2
  All notable changes to this project will be documented in this file.
3
3
  This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
4
4
 
5
- A list of unreleased changes can be found [here](https://github.com/SAP/ui5-builder/compare/v3.0.9...HEAD).
5
+ A list of unreleased changes can be found [here](https://github.com/SAP/ui5-builder/compare/v3.1.0...HEAD).
6
+
7
+ <a name="v3.1.0"></a>
8
+ ## [v3.1.0] - 2023-10-11
9
+ ### Bug Fixes
10
+ - **bundle/Builder:** Add missing 'names' attribute to generated source maps [`57e0e50`](https://github.com/SAP/ui5-builder/commit/57e0e5047638a9a704a696b8af7780fbbceefbc4)
11
+
12
+ ### Features
13
+ - Validate apiVersion property in Library.init ([#943](https://github.com/SAP/ui5-builder/issues/943)) [`52bf258`](https://github.com/SAP/ui5-builder/commit/52bf25842a59c3fa1ddbe71b482b232e18b55288)
14
+ - **Minifier:** Support input source maps ([#780](https://github.com/SAP/ui5-builder/issues/780)) [`67ecb27`](https://github.com/SAP/ui5-builder/commit/67ecb27e44a2d84e6b2203f31049220dcbcd41f0)
15
+
16
+ ### Reverts
17
+ - [INTERNAL] Azure Pipelines: Switch back to Node 20.5.x
18
+
6
19
 
7
20
  <a name="v3.0.9"></a>
8
- ## [v3.0.9] - 2023-07-25
21
+ ## [v3.0.9] - 2023-07-26
9
22
 
10
23
  <a name="v3.0.8"></a>
11
24
  ## [v3.0.8] - 2023-07-19
@@ -819,6 +832,7 @@ to load the custom bundle file instead.
819
832
 
820
833
  ### Features
821
834
  - Add ability to configure component preloads and custom bundles [`2241e5f`](https://github.com/SAP/ui5-builder/commit/2241e5ff98fd95f1f80cc74959655ae7a9c660e7)
835
+ [v3.1.0]: https://github.com/SAP/ui5-builder/compare/v3.0.9...v3.1.0
822
836
  [v3.0.9]: https://github.com/SAP/ui5-builder/compare/v3.0.8...v3.0.9
823
837
  [v3.0.8]: https://github.com/SAP/ui5-builder/compare/v3.0.7...v3.0.8
824
838
  [v3.0.7]: https://github.com/SAP/ui5-builder/compare/v3.0.6...v3.0.7
@@ -61,6 +61,9 @@ async function analyze(resource) {
61
61
  libInfo.elements = getStringArray(value, true);
62
62
  } else if ( ["designtime", "dependencies", "extensions", "name", "version"].includes(key) ) {
63
63
  // do nothing, for all other supported properties
64
+ } else if ( key === "apiVersion" &&
65
+ (value.type === Syntax.Literal && typeof value.value === "number") ) {
66
+ // just a validation
64
67
  } else {
65
68
  log.error(
66
69
  "Unexpected property '" + key +
@@ -18,7 +18,7 @@ import BundleWriter from "./BundleWriter.js";
18
18
  import {getLogger} from "@ui5/logger";
19
19
  const log = getLogger("lbt:bundle:Builder");
20
20
 
21
- const sourceMappingUrlPattern = /\/\/# sourceMappingURL=(.+)\s*$/;
21
+ const sourceMappingUrlPattern = /\/\/# sourceMappingURL=(\S+)\s*$/;
22
22
  const httpPattern = /^https?:\/\//i;
23
23
  const xmlHtmlPrePattern = /<(?:\w+:)?pre\b/;
24
24
 
@@ -332,11 +332,18 @@ class BundleBuilder {
332
332
  throw new Error("No source map provided");
333
333
  }
334
334
 
335
+ // Reminder on the structure of line-segments in the map:
336
+ // [generatedCodeColumn, sourceIndex, sourceCodeLine, sourceCodeColumn, nameIndex]
335
337
  if (map.mappings.startsWith(";")) {
336
338
  // If first line is not already mapped (typical for comments or parentheses), add a mapping to
337
339
  // make sure that dev-tools (especially Chrome's) don't choose the end of the preceding module
338
340
  // when the user tries to set a breakpoint from the bundle file
339
341
  map.mappings = "AAAA" + map.mappings;
342
+ } else if (this.outW.columnOffset === 0 && !map.mappings.startsWith("A")) {
343
+ // If first column of the first line is not already mapped, add a mapping for the same reason as above.
344
+ // This is typical for transpiled code, where there is a bunch of generated code at the beginning that
345
+ // can't be mapped to the original source
346
+ map.mappings = "AAAA," + map.mappings;
340
347
  }
341
348
 
342
349
  map.sourceRoot = path.posix.relative(
@@ -592,9 +599,7 @@ class BundleBuilder {
592
599
  `Attempting to find a source map resource based on the module's path: ${sourceMapFileCandidate}`);
593
600
  try {
594
601
  const sourceMapResource = await this.pool.findResource(sourceMapFileCandidate);
595
- if (sourceMapResource) {
596
- moduleSourceMap = (await sourceMapResource.buffer()).toString();
597
- }
602
+ moduleSourceMap = (await sourceMapResource.buffer()).toString();
598
603
  } catch (e) {
599
604
  // No input source map
600
605
  log.silly(`Could not find a source map for module ${moduleName}: ${e.message}`);
@@ -772,6 +777,7 @@ async function transform(changes, moduleContent, moduleSourceMap) {
772
777
  function createTransientSourceMap({moduleName, moduleContent, includeContent = false}) {
773
778
  const sourceMap = {
774
779
  version: 3,
780
+ names: [],
775
781
  sources: [moduleName],
776
782
  // TODO: check whether moduleContent.match() with \n is better w.r.t performance/memory usage
777
783
  mappings: encodeMappings(moduleContent.split("\n").map((line, i) => {
@@ -1,5 +1,6 @@
1
1
  import {fileURLToPath} from "node:url";
2
2
  import posixPath from "node:path/posix";
3
+ import {promisify} from "node:util";
3
4
  import os from "node:os";
4
5
  import workerpool from "workerpool";
5
6
  import Resource from "@ui5/fs/Resource";
@@ -13,6 +14,9 @@ const MAX_WORKERS = 4;
13
14
  const osCpus = os.cpus().length || 1;
14
15
  const maxWorkers = Math.max(Math.min(osCpus - 1, MAX_WORKERS), MIN_WORKERS);
15
16
 
17
+ const sourceMappingUrlPattern = /\/\/# sourceMappingURL=(\S+)\s*$/;
18
+ const httpPattern = /^https?:\/\//i;
19
+
16
20
  // Shared workerpool across all executions until the taskUtil cleanup is triggered
17
21
  let pool;
18
22
 
@@ -38,6 +42,63 @@ async function minifyInWorker(options, taskUtil) {
38
42
  return getPool(taskUtil).exec("execMinification", [options]);
39
43
  }
40
44
 
45
+ async function extractAndRemoveSourceMappingUrl(resource) {
46
+ const resourceContent = await resource.getString();
47
+ const resourcePath = resource.getPath();
48
+ const sourceMappingUrlMatch = resourceContent.match(sourceMappingUrlPattern);
49
+ if (sourceMappingUrlMatch) {
50
+ const sourceMappingUrl = sourceMappingUrlMatch[1];
51
+ if (log.isLevelEnabled("silly")) {
52
+ log.silly(`Found source map reference in content of resource ${resourcePath}: ${sourceMappingUrl}`);
53
+ }
54
+
55
+ // Strip sourceMappingURL from the resource to be minified
56
+ // It is not required anymore and will be replaced for in the minified resource
57
+ // and its debug variant anyways
58
+ resource.setString(resourceContent.replace(sourceMappingUrlPattern, ""));
59
+ return sourceMappingUrl;
60
+ }
61
+ return null;
62
+ }
63
+
64
+ async function getSourceMapFromUrl({sourceMappingUrl, resourcePath, readFile}) {
65
+ // =======================================================================
66
+ // This code is almost identical to code located in lbt/bundle/Builder.js
67
+ // Please try to update both places when making changes
68
+ // =======================================================================
69
+ if (sourceMappingUrl.startsWith("data:")) {
70
+ // Data-URI indicates an inline source map
71
+ const expectedTypeAndEncoding = "data:application/json;charset=utf-8;base64,";
72
+ if (sourceMappingUrl.startsWith(expectedTypeAndEncoding)) {
73
+ const base64Content = sourceMappingUrl.slice(expectedTypeAndEncoding.length);
74
+ // Create a resource with a path suggesting it's the source map for the resource
75
+ // (which it is but inlined)
76
+ return Buffer.from(base64Content, "base64").toString();
77
+ } else {
78
+ log.warn(
79
+ `Source map reference in resource ${resourcePath} is a data URI but has an unexpected` +
80
+ `encoding: ${sourceMappingUrl}. Expected it to start with ` +
81
+ `"data:application/json;charset=utf-8;base64,"`);
82
+ }
83
+ } else if (httpPattern.test(sourceMappingUrl)) {
84
+ log.warn(`Source map reference in resource ${resourcePath} is an absolute URL. ` +
85
+ `Currently, only relative URLs are supported.`);
86
+ } else if (posixPath.isAbsolute(sourceMappingUrl)) {
87
+ log.warn(`Source map reference in resource ${resourcePath} is an absolute path. ` +
88
+ `Currently, only relative paths are supported.`);
89
+ } else {
90
+ const sourceMapPath = posixPath.join(posixPath.dirname(resourcePath), sourceMappingUrl);
91
+
92
+ try {
93
+ const sourceMapContent = await readFile(sourceMapPath);
94
+ return sourceMapContent.toString();
95
+ } catch (e) {
96
+ // No input source map
97
+ log.warn(`Unable to read source map for resource ${resourcePath}: ${e.message}`);
98
+ }
99
+ }
100
+ }
101
+
41
102
  /**
42
103
  * @public
43
104
  * @module @ui5/builder/processors/minifier
@@ -62,9 +123,16 @@ async function minifyInWorker(options, taskUtil) {
62
123
  *
63
124
  * @param {object} parameters Parameters
64
125
  * @param {@ui5/fs/Resource[]} parameters.resources List of resources to be processed
126
+ * @param {fs|module:@ui5/fs/fsInterface} parameters.fs Node fs or custom
127
+ * [fs interface]{@link module:@ui5/fs/fsInterface}. Required when setting "readSourceMappingUrl" to true
65
128
  * @param {@ui5/builder/tasks/TaskUtil|object} [parameters.taskUtil] TaskUtil instance.
66
129
  * Required when using the <code>useWorkers</code> option
67
130
  * @param {object} [parameters.options] Options
131
+ * @param {boolean} [parameters.options.readSourceMappingUrl=false]
132
+ * Whether to make use of any existing source maps referenced in the resources to be minified. Use this option to
133
+ * preserve references to the original source files, such as TypeScript files, in the generated source map.<br>
134
+ * If a resource has been modified by a previous task, any existing source map will be ignored regardless of this
135
+ * setting. This is to ensure that no inconsistent source maps are used. Check the verbose log for details.
68
136
  * @param {boolean} [parameters.options.addSourceMappingUrl=true]
69
137
  * Whether to add a sourceMappingURL reference to the end of the minified resource
70
138
  * @param {boolean} [parameters.options.useWorkers=false]
@@ -72,8 +140,14 @@ async function minifyInWorker(options, taskUtil) {
72
140
  * @returns {Promise<module:@ui5/builder/processors/minifier~MinifierResult[]>}
73
141
  * Promise resolving with object of resource, dbgResource and sourceMap
74
142
  */
75
- export default async function({resources, taskUtil, options: {addSourceMappingUrl = true, useWorkers = false} = {}}) {
143
+ export default async function({
144
+ resources, fs, taskUtil, options: {readSourceMappingUrl = false, addSourceMappingUrl = true, useWorkers = false
145
+ } = {}}) {
76
146
  let minify;
147
+ if (readSourceMappingUrl && !fs) {
148
+ throw new Error(`Option 'readSourceMappingUrl' requires parameter 'fs' to be provided`);
149
+ }
150
+
77
151
  if (useWorkers) {
78
152
  if (!taskUtil) {
79
153
  // TaskUtil is required for worker support
@@ -86,12 +160,11 @@ export default async function({resources, taskUtil, options: {addSourceMappingUr
86
160
  }
87
161
 
88
162
  return Promise.all(resources.map(async (resource) => {
89
- const dbgPath = resource.getPath().replace(debugFileRegex, "-dbg$1");
90
- const dbgResource = await resource.clone();
91
- dbgResource.setPath(dbgPath);
163
+ const resourcePath = resource.getPath();
164
+ const dbgPath = resourcePath.replace(debugFileRegex, "-dbg$1");
165
+ const dbgFilename = posixPath.basename(dbgPath);
92
166
 
93
167
  const filename = posixPath.basename(resource.getPath());
94
- const code = await resource.getString();
95
168
 
96
169
  const sourceMapOptions = {
97
170
  filename
@@ -99,7 +172,81 @@ export default async function({resources, taskUtil, options: {addSourceMappingUr
99
172
  if (addSourceMappingUrl) {
100
173
  sourceMapOptions.url = filename + ".map";
101
174
  }
102
- const dbgFilename = posixPath.basename(dbgPath);
175
+
176
+ // Remember contentModified flag before making changes to the resource via setString
177
+ const resourceContentModified = resource.getSourceMetadata()?.contentModified;
178
+
179
+ // In any case: Extract *and remove* source map reference from resource before cloning it
180
+ const sourceMappingUrl = await extractAndRemoveSourceMappingUrl(resource);
181
+
182
+ const code = await resource.getString();
183
+ // Create debug variant based off the original resource before minification
184
+ const dbgResource = await resource.clone();
185
+ dbgResource.setPath(dbgPath);
186
+
187
+ let dbgSourceMapResource;
188
+ if (sourceMappingUrl) {
189
+ if (resourceContentModified) {
190
+ log.verbose(
191
+ `Source map found in resource will be ignored because the resource has been ` +
192
+ `modified in a previous task: ${resourcePath}`);
193
+ } else if (readSourceMappingUrl) {
194
+ // Try to find a source map reference in the to-be-minified resource
195
+ // If we find one, provide it to terser as an input source map and keep using it for the
196
+ // debug variant of the resource
197
+ const sourceMapContent = await getSourceMapFromUrl({
198
+ sourceMappingUrl,
199
+ resourcePath,
200
+ readFile: promisify(fs.readFile)
201
+ });
202
+
203
+ if (sourceMapContent) {
204
+ const sourceMapJson = JSON.parse(sourceMapContent);
205
+
206
+ if (sourceMapJson.sections) {
207
+ // TODO 4.0
208
+ // Module "@jridgewell/trace-mapping" (used by Terser) can't handle index map sections lacking
209
+ // a "names" array. Since this is a common occurrence for UI5 Tooling bundles, we search for
210
+ // such cases here and fix them until https://github.com/jridgewell/trace-mapping/pull/29 is
211
+ // resolved and Terser upgraded the dependency
212
+
213
+ // Create a dedicated clone before modifying the source map as to not alter the debug source map
214
+ const clonedSourceMapJson = JSON.parse(sourceMapContent);
215
+ clonedSourceMapJson.sections.forEach(({map}) => {
216
+ if (!map.names) {
217
+ // Add missing names array
218
+ map.names = [];
219
+ }
220
+ });
221
+ // Use modified source map as input source map
222
+ sourceMapOptions.content = JSON.stringify(clonedSourceMapJson);
223
+ } else {
224
+ // Provide source map to terser as "input source map"
225
+ sourceMapOptions.content = sourceMapContent;
226
+ }
227
+
228
+ // Use the original source map for the debug variant of the resource
229
+ // First update the file reference within the source map
230
+ sourceMapJson.file = dbgFilename;
231
+
232
+ // Then create a new resource
233
+ dbgSourceMapResource = new Resource({
234
+ string: JSON.stringify(sourceMapJson),
235
+ path: dbgPath + ".map"
236
+ });
237
+ // And reference the resource in the debug resource
238
+ dbgResource.setString(code + `//# sourceMappingURL=${dbgFilename}.map\n`);
239
+ }
240
+ } else {
241
+ // If the original resource content was unmodified and the input source map was not parsed,
242
+ // re-add the original source map reference to the debug variant
243
+ if (!sourceMappingUrl.startsWith("data:") && !sourceMappingUrl.endsWith(filename + ".map")) {
244
+ // Do not re-add inline source maps as well as references to the source map of
245
+ // the minified resource
246
+ dbgResource.setString(code + `//# sourceMappingURL=${sourceMappingUrl}\n`);
247
+ }
248
+ }
249
+ }
103
250
 
104
251
  const result = await minify({
105
252
  filename,
@@ -112,6 +259,9 @@ export default async function({resources, taskUtil, options: {addSourceMappingUr
112
259
  path: resource.getPath() + ".map",
113
260
  string: result.map
114
261
  });
115
- return {resource, dbgResource, sourceMapResource};
262
+ return {resource, dbgResource, sourceMapResource, dbgSourceMapResource};
116
263
  }));
117
264
  }
265
+
266
+ export const __localFunctions__ = (process.env.NODE_ENV === "test") ?
267
+ {getSourceMapFromUrl} : undefined;
@@ -62,7 +62,9 @@ export default async function execMinification({
62
62
  // Note: err.filename contains the debug-name
63
63
  throw new Error(
64
64
  `Minification failed with error: ${err.message} in file ${filename} ` +
65
- `(line ${err.line}, col ${err.col}, pos ${err.pos})`);
65
+ `(line ${err.line}, col ${err.col}, pos ${err.pos})`, {
66
+ cause: err
67
+ });
66
68
  }
67
69
  }
68
70
 
@@ -18,8 +18,8 @@ export default function({resources, taskUtil}) {
18
18
  const moduleNameMapping = Object.create(null);
19
19
  for (let i = resources.length - 1; i >= 0; i--) {
20
20
  const resource = resources[i];
21
- if (taskUtil.getTag(resource, taskUtil.STANDARD_TAGS.IsDebugVariant)) {
22
- const resourcePath = resource.getPath();
21
+ const resourcePath = resource.getPath();
22
+ if (resourcePath.endsWith(".js") && taskUtil.getTag(resource, taskUtil.STANDARD_TAGS.IsDebugVariant)) {
23
23
  const nonDbgPath = getNonDebugName(resourcePath);
24
24
  if (!nonDbgPath) {
25
25
  throw new Error(`Failed to resolve non-debug name for ${resourcePath}`);
@@ -1,4 +1,5 @@
1
1
  import minifier from "../processors/minifier.js";
2
+ import fsInterface from "@ui5/fs/fsInterface";
2
3
 
3
4
  /**
4
5
  * @public
@@ -19,20 +20,29 @@ import minifier from "../processors/minifier.js";
19
20
  * @param {string} parameters.options.pattern Pattern to locate the files to be processed
20
21
  * @param {boolean} [parameters.options.omitSourceMapResources=false] Whether source map resources shall
21
22
  * be tagged as "OmitFromBuildResult" and no sourceMappingURL shall be added to the minified resource
23
+ * @param {boolean} [parameters.options.useInputSourceMaps=true] Whether to make use of any existing source
24
+ * maps referenced in the resources to be minified. Use this option to preserve reference to the original
25
+ * source files, such as TypeScript files, in the generated source map.
22
26
  * @returns {Promise<undefined>} Promise resolving with <code>undefined</code> once data has been written
23
27
  */
24
- export default async function({workspace, taskUtil, options: {pattern, omitSourceMapResources = false}}) {
28
+ export default async function({
29
+ workspace, taskUtil, options: {pattern, omitSourceMapResources = false, useInputSourceMaps = true
30
+ }}) {
25
31
  const resources = await workspace.byGlob(pattern);
26
32
  const processedResources = await minifier({
27
33
  resources,
34
+ fs: fsInterface(workspace),
28
35
  taskUtil,
29
36
  options: {
30
37
  addSourceMappingUrl: !omitSourceMapResources,
38
+ readSourceMappingUrl: !!useInputSourceMaps,
31
39
  useWorkers: !!taskUtil,
32
40
  }
33
41
  });
34
42
 
35
- return Promise.all(processedResources.map(async ({resource, dbgResource, sourceMapResource}) => {
43
+ return Promise.all(processedResources.map(async ({
44
+ resource, dbgResource, sourceMapResource, dbgSourceMapResource
45
+ }) => {
36
46
  if (taskUtil) {
37
47
  taskUtil.setTag(resource, taskUtil.STANDARD_TAGS.HasDebugVariant);
38
48
  taskUtil.setTag(dbgResource, taskUtil.STANDARD_TAGS.IsDebugVariant);
@@ -40,11 +50,18 @@ export default async function({workspace, taskUtil, options: {pattern, omitSourc
40
50
  if (omitSourceMapResources) {
41
51
  taskUtil.setTag(sourceMapResource, taskUtil.STANDARD_TAGS.OmitFromBuildResult);
42
52
  }
53
+ if (dbgSourceMapResource) {
54
+ taskUtil.setTag(dbgSourceMapResource, taskUtil.STANDARD_TAGS.IsDebugVariant);
55
+ if (omitSourceMapResources) {
56
+ taskUtil.setTag(dbgSourceMapResource, taskUtil.STANDARD_TAGS.OmitFromBuildResult);
57
+ }
58
+ }
43
59
  }
44
60
  return Promise.all([
45
61
  workspace.write(resource),
46
62
  workspace.write(dbgResource),
47
- workspace.write(sourceMapResource)
63
+ workspace.write(sourceMapResource),
64
+ dbgSourceMapResource && workspace.write(dbgSourceMapResource)
48
65
  ]);
49
66
  }));
50
67
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ui5/builder",
3
- "version": "3.0.9",
3
+ "version": "3.1.0",
4
4
  "description": "UI5 Tooling - Builder",
5
5
  "author": {
6
6
  "name": "SAP SE",
@@ -119,7 +119,7 @@
119
119
  },
120
120
  "dependencies": {
121
121
  "@jridgewell/sourcemap-codec": "^1.4.15",
122
- "@ui5/fs": "^3.0.4",
122
+ "@ui5/fs": "^3.0.5",
123
123
  "@ui5/logger": "^3.0.0",
124
124
  "cheerio": "1.0.0-rc.12",
125
125
  "escape-unicode": "^0.2.0",
@@ -129,31 +129,33 @@
129
129
  "jsdoc": "^4.0.2",
130
130
  "less-openui5": "^0.11.6",
131
131
  "pretty-data": "^0.40.0",
132
- "rimraf": "^5.0.1",
132
+ "rimraf": "^5.0.5",
133
133
  "semver": "^7.5.4",
134
- "terser": "^5.19.2",
135
- "workerpool": "^6.4.0",
136
- "xml2js": "^0.6.0"
134
+ "terser": "^5.21.0",
135
+ "workerpool": "^6.5.0",
136
+ "xml2js": "^0.6.2"
137
137
  },
138
138
  "devDependencies": {
139
139
  "@istanbuljs/esm-loader-hook": "^0.2.0",
140
- "@ui5/project": "^3.4.2",
140
+ "@jridgewell/trace-mapping": "^0.3.19",
141
+ "@ui5/project": "^3.7.1",
141
142
  "ava": "^5.3.1",
142
- "chai": "^4.3.7",
143
+ "chai": "^4.3.10",
143
144
  "chai-fs": "^2.0.0",
144
145
  "chokidar-cli": "^3.0.0",
145
146
  "cross-env": "^7.0.3",
146
- "depcheck": "^1.4.3",
147
- "docdash": "^2.0.1",
148
- "eslint": "^8.45.0",
147
+ "depcheck": "^1.4.6",
148
+ "docdash": "^2.0.2",
149
+ "eslint": "^8.51.0",
149
150
  "eslint-config-google": "^0.14.0",
150
151
  "eslint-plugin-ava": "^14.0.0",
151
- "eslint-plugin-jsdoc": "^46.4.4",
152
- "esmock": "^2.3.2",
152
+ "eslint-plugin-jsdoc": "^46.8.2",
153
+ "esmock": "^2.5.2",
154
+ "line-column": "^1.0.2",
153
155
  "nyc": "^15.1.0",
154
156
  "open-cli": "^7.2.0",
155
157
  "recursive-readdir": "^2.2.3",
156
- "sinon": "^15.2.0",
158
+ "sinon": "^16.1.0",
157
159
  "tap-xunit": "^2.4.1"
158
160
  }
159
161
  }