@emulsify/core 4.3.2 → 4.5.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.
Files changed (54) hide show
  1. package/.storybook/main-static-assets.js +5 -8
  2. package/.storybook/main-vite.js +11 -3
  3. package/README.md +14 -6
  4. package/config/a11y-wcag22.js +11 -0
  5. package/config/vite/entries.js +7 -2
  6. package/config/vite/environment.js +4 -0
  7. package/config/vite/plugins/assets/asset-url-rebase.js +241 -0
  8. package/config/vite/plugins/assets/copy-src-assets.js +82 -12
  9. package/config/vite/plugins/assets/copy-twig-files.js +85 -16
  10. package/config/vite/plugins/assets/css-asset-rebase.js +306 -0
  11. package/config/vite/plugins/assets/css-asset-relativizer.js +301 -21
  12. package/config/vite/plugins/assets/development-source-maps.js +273 -0
  13. package/config/vite/plugins/assets/mirror-components.js +98 -82
  14. package/config/vite/plugins/assets/output-freshness.js +235 -0
  15. package/config/vite/plugins/assets/source-file-index.js +13 -13
  16. package/config/vite/plugins/assets/stable-watch-output.js +165 -0
  17. package/config/vite/plugins/assets/storybook-output.js +27 -0
  18. package/config/vite/plugins/index.js +95 -9
  19. package/config/vite/plugins/reporter/asset-resolver.js +34 -6
  20. package/config/vite/plugins/reporter/build-errors.js +7 -3
  21. package/config/vite/plugins/reporter/diagnostics.js +140 -10
  22. package/config/vite/plugins/reporter/index.js +380 -75
  23. package/config/vite/plugins/reporter/render.js +297 -44
  24. package/config/vite/plugins/reporter/sass-logger.js +30 -0
  25. package/config/vite/plugins/reporter/source-roots.js +101 -21
  26. package/config/vite/plugins/reporter/strict-mode.js +99 -0
  27. package/config/vite/plugins/reporter/vite-logger.js +220 -8
  28. package/config/vite/plugins/reporter/watch-mode.js +6 -2
  29. package/config/vite/plugins/twig/twig-module.js +35 -258
  30. package/config/vite/plugins/twig/virtual-twig-asset-sources.js +48 -49
  31. package/config/vite/project-config.js +121 -21
  32. package/config/vite/project-structure.js +6 -0
  33. package/config/vite/utils/asset-roots.js +205 -0
  34. package/config/vite/utils/css-urls.js +350 -0
  35. package/config/vite/utils/fs-safe.js +38 -1
  36. package/config/vite/utils/source-directory-skips.js +13 -0
  37. package/config/vite/utils/source-maps.js +88 -0
  38. package/config/vite/utils/twig-component-resolver.js +316 -0
  39. package/config/vite/vite.config.js +106 -42
  40. package/package.json +54 -40
  41. package/scripts/a11y.js +88 -9
  42. package/scripts/audit/checks/css-asset-references.js +256 -24
  43. package/scripts/audit/checks/twig-references.js +16 -5
  44. package/scripts/audit/fix.js +836 -0
  45. package/scripts/audit/index.js +10 -2
  46. package/scripts/audit/lib/css.js +41 -35
  47. package/scripts/audit/lib/story-ast.js +392 -0
  48. package/scripts/audit/lib/story-render-paths.js +600 -0
  49. package/scripts/audit/lib/story-selection.js +190 -0
  50. package/scripts/audit/lib/twig.js +372 -80
  51. package/scripts/audit/report.js +83 -5
  52. package/scripts/audit-twig-stories.js +73 -3
  53. package/scripts/audit.js +87 -2
  54. package/src/storybook/twig/source-function.js +14 -10
@@ -0,0 +1,600 @@
1
+ /**
2
+ * @file Classify modern and legacy Twig render paths in Storybook stories.
3
+ */
4
+
5
+ import {
6
+ collectModuleDeclarations,
7
+ isNode,
8
+ requiredTwigSpecifier,
9
+ readStaticProperty,
10
+ staticPropertyName,
11
+ unwrapExpression,
12
+ } from './story-ast.js';
13
+ import { selectStoryExports, isStaticFalsy } from './story-selection.js';
14
+
15
+ const FUNCTION_TYPES = new Set([
16
+ 'ArrowFunctionExpression',
17
+ 'ClassMethod',
18
+ 'ClassPrivateMethod',
19
+ 'FunctionDeclaration',
20
+ 'FunctionExpression',
21
+ 'ObjectMethod',
22
+ ]);
23
+ const SHADOWED_BINDING = Symbol('shadowed binding');
24
+
25
+ /**
26
+ * Return direct AST children for a node.
27
+ *
28
+ * @param {object} node - Babel AST node.
29
+ * @returns {object[]} Direct child nodes.
30
+ */
31
+ function childNodes(node) {
32
+ const children = [];
33
+
34
+ for (const value of Object.values(node)) {
35
+ if (Array.isArray(value)) {
36
+ children.push(...value.filter(isNode));
37
+ } else if (isNode(value)) {
38
+ children.push(value);
39
+ }
40
+ }
41
+
42
+ return children;
43
+ }
44
+
45
+ /**
46
+ * Get a one-based source line for a node.
47
+ *
48
+ * @param {object} node - Babel AST node.
49
+ * @returns {number} One-based line number.
50
+ */
51
+ function nodeLine(node) {
52
+ return Number.isInteger(node?.loc?.start?.line) ? node.loc.start.line : 1;
53
+ }
54
+
55
+ /**
56
+ * Determine whether a call uses Function.prototype.bind().
57
+ *
58
+ * @param {object} node - Babel expression.
59
+ * @returns {boolean} TRUE for X.bind(...).
60
+ */
61
+ function isBindCall(node) {
62
+ return (
63
+ node?.type === 'CallExpression' &&
64
+ node.callee?.type === 'MemberExpression' &&
65
+ staticPropertyName(node.callee) === 'bind'
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Resolve a selected export to the render Storybook would use.
71
+ *
72
+ * Function stories supply their own render. Object stories inherit metadata's
73
+ * render only when their own render is absent or statically falsy. Unknown
74
+ * values remain unresolved instead of being assumed to use a modern default.
75
+ *
76
+ * @param {object} node - Selected story or render value.
77
+ * @param {object} context - Reusable module information and lexical bindings.
78
+ * @param {object} defaultRender - Known, absent, or unknown metadata render.
79
+ * @param {object} [lineNode] - Preferred source location.
80
+ * @param {Set<object>} [visited] - Nodes on this resolution path.
81
+ * @param {boolean} [storyValue] - Whether an object represents a CSF story.
82
+ * @returns {object} Render node and location, or an explicit unknown result.
83
+ */
84
+ function resolveRenderPath(
85
+ node,
86
+ context,
87
+ defaultRender,
88
+ lineNode = null,
89
+ visited = new Set(),
90
+ storyValue = true,
91
+ ) {
92
+ const { declarations } = context;
93
+ const value = unwrapExpression(node);
94
+ if (!isNode(value) || visited.has(value)) return { unknown: true };
95
+ visited.add(value);
96
+
97
+ if (value.type === 'Identifier' && declarations.has(value.name)) {
98
+ const declaration = declarations.get(value.name);
99
+ return resolveRenderPath(
100
+ declaration.value,
101
+ context,
102
+ defaultRender,
103
+ declaration.node,
104
+ visited,
105
+ storyValue,
106
+ );
107
+ }
108
+
109
+ if (value.type === 'MemberExpression') {
110
+ const resolved = resolveBindingValue(value, context);
111
+ if (!resolved || resolved === value) return { unknown: true };
112
+ return resolveRenderPath(
113
+ resolved,
114
+ context,
115
+ defaultRender,
116
+ lineNode,
117
+ visited,
118
+ storyValue,
119
+ );
120
+ }
121
+
122
+ if (isBindCall(value)) {
123
+ return resolveRenderPath(
124
+ value.callee.object,
125
+ context,
126
+ defaultRender,
127
+ lineNode,
128
+ visited,
129
+ false,
130
+ );
131
+ }
132
+
133
+ if (value.type === 'ObjectExpression' && storyValue) {
134
+ let render = readStaticProperty(value, 'render', declarations);
135
+ if (
136
+ render.state === 'absent' ||
137
+ (render.state === 'known' && isStaticFalsy(render.node, declarations))
138
+ ) {
139
+ render = defaultRender;
140
+ }
141
+ if (render.state !== 'known') return { unknown: true };
142
+ return resolveRenderPath(
143
+ render.node,
144
+ context,
145
+ defaultRender,
146
+ render.lineNode,
147
+ visited,
148
+ false,
149
+ );
150
+ }
151
+
152
+ return {
153
+ node: value,
154
+ lineNode: lineNode || value,
155
+ unknown: !isFunctionNode(value),
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Add identifiers declared by a binding pattern to a scope.
161
+ *
162
+ * @param {object} pattern - Babel binding pattern.
163
+ * @param {Map<string, unknown>} bindings - Scope binding map.
164
+ * @param {unknown} value - Value associated with a simple identifier.
165
+ * @returns {void}
166
+ */
167
+ function addPatternBindings(pattern, bindings, value = SHADOWED_BINDING) {
168
+ if (!isNode(pattern)) return;
169
+
170
+ if (pattern.type === 'Identifier') {
171
+ bindings.set(pattern.name, value);
172
+ return;
173
+ }
174
+
175
+ if (pattern.type === 'AssignmentPattern') {
176
+ addPatternBindings(pattern.left, bindings, value);
177
+ return;
178
+ }
179
+
180
+ if (pattern.type === 'RestElement') {
181
+ addPatternBindings(pattern.argument, bindings, value);
182
+ return;
183
+ }
184
+
185
+ if (pattern.type === 'ObjectPattern') {
186
+ for (const property of pattern.properties) {
187
+ if (property.type === 'RestElement') {
188
+ addPatternBindings(property.argument, bindings, value);
189
+ } else {
190
+ addPatternBindings(property.value, bindings, value);
191
+ }
192
+ }
193
+ return;
194
+ }
195
+
196
+ if (pattern.type === 'ArrayPattern') {
197
+ for (const element of pattern.elements) {
198
+ addPatternBindings(element, bindings, value);
199
+ }
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Collect declarations owned directly by a block scope.
205
+ *
206
+ * @param {object} block - Babel block statement.
207
+ * @returns {Map<string, unknown>} Block binding map.
208
+ */
209
+ function collectBlockBindings(block) {
210
+ const bindings = new Map();
211
+
212
+ for (const statement of block.body || []) {
213
+ if (statement.type === 'VariableDeclaration') {
214
+ for (const declarator of statement.declarations) {
215
+ if (declarator.id?.type === 'Identifier') {
216
+ bindings.set(declarator.id.name, declarator.init || SHADOWED_BINDING);
217
+ } else {
218
+ addPatternBindings(declarator.id, bindings);
219
+ }
220
+ }
221
+ } else if (
222
+ (statement.type === 'FunctionDeclaration' ||
223
+ statement.type === 'ClassDeclaration') &&
224
+ statement.id?.name
225
+ ) {
226
+ bindings.set(statement.id.name, statement);
227
+ }
228
+ }
229
+
230
+ return bindings;
231
+ }
232
+
233
+ /**
234
+ * Collect function parameters and its local name as lexical bindings.
235
+ *
236
+ * @param {object} functionNode - Babel function node.
237
+ * @returns {Map<string, unknown>} Function binding map.
238
+ */
239
+ function collectFunctionBindings(functionNode) {
240
+ const bindings = new Map();
241
+
242
+ if (functionNode.id?.name) {
243
+ bindings.set(functionNode.id.name, functionNode);
244
+ }
245
+ for (const parameter of functionNode.params || []) {
246
+ addPatternBindings(parameter, bindings);
247
+ }
248
+
249
+ return bindings;
250
+ }
251
+
252
+ /**
253
+ * Find a name in a chain of local lexical scopes.
254
+ *
255
+ * @param {string} name - Identifier name.
256
+ * @param {Map<string, unknown>[]} scopes - Innermost-first scope chain.
257
+ * @returns {{found: boolean, value: unknown}} Binding lookup result.
258
+ */
259
+ function findLocalBinding(name, scopes) {
260
+ for (const scope of scopes) {
261
+ if (scope.has(name)) return { found: true, value: scope.get(name) };
262
+ }
263
+
264
+ return { found: false, value: null };
265
+ }
266
+
267
+ /**
268
+ * Visit an AST with innermost-first lexical scope maps.
269
+ *
270
+ * @param {object} node - Current Babel AST node.
271
+ * @param {Map<string, unknown>[]} scopes - Current scope chain.
272
+ * @param {(node: object, scopes: Map<string, unknown>[]) => void} visitor
273
+ * Scoped node visitor.
274
+ * @returns {void}
275
+ */
276
+ function visitScopedAst(node, scopes, visitor) {
277
+ if (!isNode(node)) return;
278
+ visitor(node, scopes);
279
+
280
+ if (isFunctionNode(node)) {
281
+ const functionScopes = [collectFunctionBindings(node), ...scopes];
282
+ for (const parameter of node.params || []) {
283
+ visitScopedAst(parameter, functionScopes, visitor);
284
+ }
285
+ visitScopedAst(node.body, functionScopes, visitor);
286
+ return;
287
+ }
288
+
289
+ if (node.type === 'BlockStatement') {
290
+ const blockScopes = [collectBlockBindings(node), ...scopes];
291
+ for (const statement of node.body) {
292
+ visitScopedAst(statement, blockScopes, visitor);
293
+ }
294
+ return;
295
+ }
296
+
297
+ if (node.type === 'CatchClause') {
298
+ const bindings = new Map();
299
+ addPatternBindings(node.param, bindings);
300
+ visitScopedAst(node.body, [bindings, ...scopes], visitor);
301
+ return;
302
+ }
303
+
304
+ for (const child of childNodes(node)) {
305
+ visitScopedAst(child, scopes, visitor);
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Record each node's lexical environment once for the module.
311
+ *
312
+ * A helper retains its declaration scope when another render path calls it.
313
+ * These maps contain bindings only, never modern/legacy classification state.
314
+ *
315
+ * @param {object} ast - Babel File or Program.
316
+ * @returns {WeakMap<object, Map<string, unknown>[]>} Declaration-site scopes.
317
+ */
318
+ function collectNodeScopes(ast) {
319
+ const scopesByNode = new WeakMap();
320
+ visitScopedAst(ast, [], (node, scopes) => scopesByNode.set(node, scopes));
321
+ return scopesByNode;
322
+ }
323
+
324
+ /**
325
+ * Determine whether a node is a function-like render value.
326
+ *
327
+ * @param {object} node - Babel AST node.
328
+ * @returns {boolean} TRUE for supported function nodes.
329
+ */
330
+ function isFunctionNode(node) {
331
+ return FUNCTION_TYPES.has(node?.type);
332
+ }
333
+
334
+ /**
335
+ * Resolve an identifier in its declaration-site lexical environment.
336
+ *
337
+ * @param {object} node - Identifier expression.
338
+ * @param {object} context - Reusable module information.
339
+ * @returns {{found: boolean, value: unknown}} Binding result.
340
+ */
341
+ function boundValue(node, context) {
342
+ const local = findLocalBinding(
343
+ node.name,
344
+ context.scopesByNode.get(node) || [],
345
+ );
346
+ if (local.found) return local;
347
+
348
+ const declaration = context.declarations.get(node.name);
349
+ return declaration
350
+ ? { found: true, value: declaration.value }
351
+ : { found: false, value: null };
352
+ }
353
+
354
+ /**
355
+ * Resolve aliases and static object properties without evaluating functions.
356
+ *
357
+ * @param {object} node - Value or function reference.
358
+ * @param {object} context - Reusable module information.
359
+ * @param {Set<object>} [active] - Current alias chain.
360
+ * @returns {object|null} Resolved value, or null for an unknown/cyclic binding.
361
+ */
362
+ function resolveBindingValue(node, context, active = new Set()) {
363
+ const value = unwrapExpression(node);
364
+ if (!isNode(value) || active.has(value)) return null;
365
+ active.add(value);
366
+
367
+ try {
368
+ if (value.type === 'Identifier') {
369
+ const binding = boundValue(value, context);
370
+ if (
371
+ context.templateNames.has(value.name) &&
372
+ requiredTwigSpecifier({ id: value, init: binding.value })
373
+ ) {
374
+ return value;
375
+ }
376
+ return binding.found
377
+ ? resolveBindingValue(binding.value, context, active)
378
+ : value;
379
+ }
380
+
381
+ if (value.type === 'MemberExpression') {
382
+ const object = resolveBindingValue(value.object, context, active);
383
+ const name = staticPropertyName(value);
384
+ if (object?.type !== 'ObjectExpression' || !name) return null;
385
+
386
+ for (const property of [...object.properties].reverse()) {
387
+ // An unknown later override makes the effective property uncertain.
388
+ if (
389
+ property.type === 'SpreadElement' ||
390
+ (property.computed && !staticPropertyName(property))
391
+ ) {
392
+ return null;
393
+ }
394
+ if (staticPropertyName(property) !== name) continue;
395
+ return resolveBindingValue(
396
+ property.type === 'ObjectMethod' ? property : property.value,
397
+ context,
398
+ active,
399
+ );
400
+ }
401
+ return null;
402
+ }
403
+
404
+ return value;
405
+ } finally {
406
+ active.delete(value);
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Determine whether a callee resolves to the imported modern renderer.
412
+ *
413
+ * @param {object} node - Callee expression, possibly aliased or bound.
414
+ * @param {object} context - Reusable module information.
415
+ * @param {Set<object>} [active] - Current bound-callee chain.
416
+ * @returns {boolean} TRUE for the public renderTwig binding.
417
+ */
418
+ function isRenderTwigCallee(node, context, active = new Set()) {
419
+ const value = resolveBindingValue(node, context);
420
+ if (!value || active.has(value)) return false;
421
+ active.add(value);
422
+
423
+ if (isBindCall(value)) {
424
+ return isRenderTwigCallee(value.callee.object, context, active);
425
+ }
426
+
427
+ return (
428
+ value.type === 'Identifier' &&
429
+ !boundValue(value, context).found &&
430
+ context.renderTwigNames.has(value.name)
431
+ );
432
+ }
433
+
434
+ /**
435
+ * Find a Twig template contributing to the current returned value.
436
+ *
437
+ * A renderTwig invocation ends this path without marking its arguments or
438
+ * declarations. Active nodes belong only to this traversal and are released
439
+ * on return, so a recursive branch cannot hide a later branch's findings.
440
+ *
441
+ * @param {object} node - Returned expression or render function.
442
+ * @param {object} context - Reusable module information.
443
+ * @param {Set<object>} active - Nodes on the current render path.
444
+ * @returns {string} Twig binding name, or an empty string.
445
+ */
446
+ function findTwigInValue(node, context, active) {
447
+ const value = unwrapExpression(node);
448
+ if (!isNode(value) || active.has(value)) return '';
449
+ active.add(value);
450
+
451
+ try {
452
+ if (isFunctionNode(value)) {
453
+ return findFunctionTwigReturn(value, context, active);
454
+ }
455
+
456
+ if (value.type === 'Identifier' || value.type === 'MemberExpression') {
457
+ const resolved = resolveBindingValue(value, context);
458
+ if (!resolved && value.type === 'MemberExpression') {
459
+ return findTwigInValue(value.object, context, active);
460
+ }
461
+ if (resolved !== value) {
462
+ return findTwigInValue(resolved, context, active);
463
+ }
464
+ return context.templateNames.has(value.name) ? value.name : '';
465
+ }
466
+
467
+ if (value.type === 'CallExpression') {
468
+ if (isRenderTwigCallee(value.callee, context)) return '';
469
+ if (isBindCall(value)) {
470
+ return findTwigInValue(value.callee.object, context, active);
471
+ }
472
+
473
+ const name = findTwigInValue(value.callee, context, active);
474
+ if (name) return name;
475
+
476
+ for (const argument of value.arguments) {
477
+ const argumentName = findTwigInValue(argument, context, active);
478
+ if (argumentName) return argumentName;
479
+ }
480
+
481
+ return '';
482
+ }
483
+
484
+ // Property names are not references to similarly named template imports.
485
+ if (value.type === 'ObjectProperty') {
486
+ return findTwigInValue(value.value, context, active);
487
+ }
488
+
489
+ for (const child of childNodes(value)) {
490
+ const name = findTwigInValue(child, context, active);
491
+ if (name) return name;
492
+ }
493
+
494
+ return '';
495
+ } finally {
496
+ active.delete(value);
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Find a Twig-returning statement without entering an uncalled nested function.
502
+ *
503
+ * @param {object} node - Current statement or expression.
504
+ * @param {object} context - Reusable module information.
505
+ * @param {Set<object>} active - Nodes on the current render path.
506
+ * @returns {string} Twig binding name, or an empty string.
507
+ */
508
+ function findTwigReturnInNode(node, context, active) {
509
+ if (!isNode(node) || isFunctionNode(node)) return '';
510
+
511
+ if (node.type === 'ReturnStatement' && node.argument) {
512
+ return findTwigInValue(node.argument, context, active);
513
+ }
514
+
515
+ for (const child of childNodes(node)) {
516
+ const name = findTwigReturnInNode(child, context, active);
517
+ if (name) return name;
518
+ }
519
+
520
+ return '';
521
+ }
522
+
523
+ /**
524
+ * Find a Twig template returned by a function render path.
525
+ *
526
+ * @param {object} functionNode - Babel function node.
527
+ * @param {object} context - Reusable module information.
528
+ * @param {Set<object>} active - Nodes on the current render path.
529
+ * @returns {string} Twig binding name, or an empty string.
530
+ */
531
+ function findFunctionTwigReturn(functionNode, context, active) {
532
+ const body = unwrapExpression(functionNode.body);
533
+
534
+ if (
535
+ functionNode.type === 'ArrowFunctionExpression' &&
536
+ body?.type !== 'BlockStatement'
537
+ ) {
538
+ return findTwigInValue(body, context, active);
539
+ }
540
+
541
+ return findTwigReturnInNode(body, context, active);
542
+ }
543
+
544
+ /**
545
+ * Classify explicit Storybook render paths that return Twig HTML directly.
546
+ *
547
+ * @param {object} ast - Babel File or Program.
548
+ * @param {object} options - Binding names used by the story module.
549
+ * @param {Iterable<string>} options.templateNames - Twig template bindings.
550
+ * @param {Iterable<string>} options.renderTwigNames - renderTwig bindings.
551
+ * @returns {object} Legacy render locations, selected-path presence, and
552
+ * internal unknown selection/render states. A clean result is not proof that
553
+ * an unresolved consumer render is modern.
554
+ */
555
+ export function classifyStoryRenderPaths(
556
+ ast,
557
+ { templateNames = [], renderTwigNames = [] } = {},
558
+ ) {
559
+ const declarations = collectModuleDeclarations(ast);
560
+ const templateNameSet = new Set(templateNames);
561
+ const renderTwigNameSet = new Set(renderTwigNames);
562
+ const scopesByNode = collectNodeScopes(ast);
563
+ const selection = selectStoryExports(ast, declarations);
564
+ let hasUnknownStoryRenderPath = false;
565
+ const legacy = [];
566
+
567
+ const context = {
568
+ declarations,
569
+ scopesByNode,
570
+ templateNames: templateNameSet,
571
+ renderTwigNames: renderTwigNameSet,
572
+ };
573
+
574
+ for (const story of selection.stories) {
575
+ const path = resolveRenderPath(
576
+ story.node,
577
+ context,
578
+ selection.defaultRender,
579
+ story.lineNode,
580
+ );
581
+ const name = findTwigInValue(path.node, context, new Set());
582
+ if (name) {
583
+ legacy.push({ name, line: nodeLine(path.lineNode) });
584
+ } else if (path.unknown) {
585
+ const resolved = resolveBindingValue(path.node, context);
586
+ const modern =
587
+ resolved?.type === 'CallExpression' &&
588
+ isRenderTwigCallee(resolved.callee, context);
589
+ if (!modern && !isFunctionNode(resolved))
590
+ hasUnknownStoryRenderPath = true;
591
+ }
592
+ }
593
+
594
+ return {
595
+ legacy,
596
+ hasStoryRenderPath: selection.stories.length > 0,
597
+ hasUnknownStorySelection: selection.hasUnknownSelection,
598
+ hasUnknownStoryRenderPath,
599
+ };
600
+ }