@akinon/pz-theme 2.0.71-rc.0 → 2.0.72-beta.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.
@@ -0,0 +1,156 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * The `'use client'` boundary inside `src/custom`, checked from source.
6
+ *
7
+ * Registration is client-only, but this folder is not. The directive-free
8
+ * modules here — `catalog.ts`, `types.ts`, `collect.ts`, `define.tsx`, the
9
+ * barrel — are reachable from the server graph and are actually read from it:
10
+ * `placeholder-registry.ts` calls `getCustomCatalog` without a directive of its
11
+ * own, and the server-only package root re-exports the barrel. React replaces
12
+ * each export of a `'use client'` module with a reference that throws
13
+ * "Attempted to call … from the server" the moment it is invoked, so one call
14
+ * from a directive-free module into a client one breaks whichever consumer is
15
+ * holding the server-side copy — and no jsdom test can see it, because jest has
16
+ * no RSC graph at all.
17
+ *
18
+ * What is refused is CALLING across the line, not importing across it: a client
19
+ * component referenced as an element type resolves fine from either graph. That
20
+ * is the distinction `bindHost` in `register.ts` leans on when it hands
21
+ * `CustomBlockHost` to `createElement` rather than calling it. The rule is also
22
+ * one-directional — a client module imports a plain one freely — and type-only
23
+ * imports are erased before the bundler sees them.
24
+ */
25
+ const CUSTOM_DIR = path.resolve(__dirname, '..');
26
+
27
+ const sourceFiles = (): string[] =>
28
+ fs
29
+ .readdirSync(CUSTOM_DIR)
30
+ .filter((entry) => /\.tsx?$/.test(entry))
31
+ .sort();
32
+
33
+ const read = (file: string): string =>
34
+ fs.readFileSync(path.join(CUSTOM_DIR, file), 'utf8');
35
+
36
+ const isClientModule = (file: string): boolean =>
37
+ /^['"]use client['"];/.test(read(file).trimStart());
38
+
39
+ /** Sibling value imports as `{ bindings, specifier }`. Type imports erase. */
40
+ const valueImports = (
41
+ source: string
42
+ ): Array<{ bindings: string[]; specifier: string }> =>
43
+ Array.from(
44
+ source.matchAll(/^import\s+(?!type\s)([^;]*?)\s+from\s+'(\.[^']*)';/gm)
45
+ )
46
+ .filter((match) => !match[2].startsWith('../'))
47
+ .map((match) => ({
48
+ specifier: match[2],
49
+ bindings: Array.from(match[1].matchAll(/[A-Za-z_$][\w$]*/g))
50
+ .map((binding) => binding[0])
51
+ .filter((name) => name !== 'type')
52
+ }));
53
+
54
+ const resolved = (specifier: string): string | null =>
55
+ ['.ts', '.tsx']
56
+ .map((extension) => `${specifier.replace(/^\.\//, '')}${extension}`)
57
+ .find((candidate) => fs.existsSync(path.join(CUSTOM_DIR, candidate))) ??
58
+ null;
59
+
60
+ /**
61
+ * Source with its comments removed.
62
+ *
63
+ * Every file here explains its boundary in prose, and the prose names the very
64
+ * symbols the scan looks for — an unstripped comment reports the rule's own
65
+ * documentation as a violation of it.
66
+ */
67
+ const code = (source: string): string =>
68
+ source.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, ' ');
69
+
70
+ const isCalled = (source: string, name: string): boolean =>
71
+ new RegExp(String.raw`\b${name}\s*\(`).test(code(source));
72
+
73
+ describe('src/custom module boundaries', () => {
74
+ it('has the client modules the render path needs and no more', () => {
75
+ // A directive here is not free — it decides which graph can call the file.
76
+ // Listing them makes adding one a deliberate edit.
77
+ expect(sourceFiles().filter(isClientModule)).toEqual([
78
+ 'block-context.ts',
79
+ 'block-host.tsx',
80
+ 'register.ts',
81
+ 'theme-editable.tsx',
82
+ 'theme-slot.tsx',
83
+ 'use-theme-setting.ts'
84
+ ]);
85
+ });
86
+
87
+ /**
88
+ * The renderer registry may only be reached from a client module.
89
+ *
90
+ * `blocks/block-renderer-registry.tsx` imports all ~99 block modules, and
91
+ * most of them call hooks without carrying a directive — they have never
92
+ * needed one, because the only path that reaches them is `theme-block.tsx`,
93
+ * which is a client module. Importing the registry from a directive-free
94
+ * module puts that whole tree into the importer's graph, and a brand barrel
95
+ * imported from a server layout then fails the BRAND's build at
96
+ * `accordion-block.tsx:1` with "You're importing a module that depends on
97
+ * `useState` into a React Server Component module". `register.ts` carries
98
+ * `'use client'` for exactly this reason, and that is what makes registration
99
+ * client-only.
100
+ *
101
+ * This is precisely the case the first version of this file could not see:
102
+ * it filtered `../` specifiers out, and the offending import is
103
+ * `../blocks/block-renderer-registry`. It cost nothing in either suite —
104
+ * jest has no RSC graph, so 1741 tests stayed green over a defect that broke
105
+ * the first brand build attempted.
106
+ */
107
+ it('reaches the block renderer registry only from a client module', () => {
108
+ const withoutTypeImports = (source: string): string =>
109
+ code(source).replace(/import\s+type[^;]*;/g, ' ');
110
+
111
+ const offenders = sourceFiles()
112
+ .filter((file) => !isClientModule(file))
113
+ .filter((file) =>
114
+ /from\s+'\.\.\/blocks\/block-renderer-registry'/.test(
115
+ withoutTypeImports(read(file))
116
+ )
117
+ );
118
+
119
+ expect(offenders).toEqual([]);
120
+ });
121
+
122
+ it('never calls into a client module from a server-reachable one', () => {
123
+ const offences: string[] = [];
124
+
125
+ sourceFiles()
126
+ .filter((file) => !isClientModule(file))
127
+ .forEach((file) => {
128
+ const source = read(file);
129
+
130
+ valueImports(source).forEach(({ bindings, specifier }) => {
131
+ const target = resolved(specifier);
132
+ if (!target || !isClientModule(target)) return;
133
+
134
+ bindings
135
+ .filter((name) => isCalled(source, name))
136
+ .forEach((name) => {
137
+ offences.push(`${file} calls ${name}() from ${target}`);
138
+ });
139
+ });
140
+ });
141
+
142
+ expect(offences).toEqual([]);
143
+ });
144
+
145
+ it('keeps the barrel free of a directive of its own', () => {
146
+ // Still worth pinning under client-only registration, for the opposite
147
+ // reason to the one it was written for. The barrel publishes more than the
148
+ // client half: `getCustomCatalog`, `isCustomThemeType` and
149
+ // `CUSTOM_TYPE_PREFIX` all come from directive-free modules, and the
150
+ // server-only package root re-exports every one of them. A directive here
151
+ // would turn those into client references, so a server module calling
152
+ // `isCustomThemeType` from `@akinon/pz-theme` would throw. `register.ts`
153
+ // carries its own directive already, so the barrel would gain nothing.
154
+ expect(isClientModule('index.ts')).toBe(false);
155
+ });
156
+ });
@@ -0,0 +1,427 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import React from 'react';
4
+ import { render, screen } from '@testing-library/react';
5
+
6
+ // The recursive renderer, stubbed with a countable marker so a slot's children
7
+ // are assertable without every block renderer being dragged into the
8
+ // assertion. Same stand-in `group-block.test.tsx` uses, and for the same
9
+ // reason — `renderComposedChildren` is now shared by both.
10
+ jest.mock('../../theme-block', () => ({
11
+ __esModule: true,
12
+ default: ({ block }: { block: { id: string } }) => (
13
+ <div data-testid="slot-child" data-id={block.id} />
14
+ )
15
+ }));
16
+
17
+ import blockRendererRegistry from '../../blocks/block-renderer-registry';
18
+ import type { BlockRendererProps } from '../../blocks/block-renderer-registry';
19
+ import { resetCustomCatalog } from '../catalog';
20
+ import { resetCustomCollection } from '../collect';
21
+
22
+ // Through the barrel, never the individual modules. That is the point of this
23
+ // file: the three pieces each have their own suite, and every one of them
24
+ // imports its subject directly, so a barrel that exported nothing at all would
25
+ // still leave 86 green tests behind — while `@akinon/pz-theme/src/custom`, the
26
+ // path a brand actually types, resolved to nothing.
27
+ import {
28
+ CUSTOM_SLOT_BLOCK_TYPE,
29
+ CUSTOM_TYPE_PREFIX,
30
+ defineCustomBlock,
31
+ getCustomCatalog,
32
+ isCustomThemeType,
33
+ partAttrs,
34
+ registerCustomBlocks,
35
+ SLOT_NAME_PROPERTY,
36
+ ThemeEditable,
37
+ ThemeSlot,
38
+ useThemeSetting
39
+ } from '../index';
40
+ import * as customBarrel from '../index';
41
+
42
+ const rendererMap = (): Map<string, unknown> =>
43
+ (blockRendererRegistry as unknown as { renderers: Map<string, unknown> })
44
+ .renderers;
45
+
46
+ const clearCustomRenderers = (): void => {
47
+ const map = rendererMap();
48
+ Array.from(map.keys())
49
+ .filter(isCustomThemeType)
50
+ .forEach((type) => map.delete(type));
51
+ };
52
+
53
+ /** The ticket's own example, unaltered. If this stops compiling, the API broke. */
54
+ function PromoStrip() {
55
+ const tone = useThemeSetting('tone', {
56
+ type: 'select',
57
+ label: 'Tone',
58
+ options: ['light', 'dark'],
59
+ default: 'light'
60
+ });
61
+
62
+ return (
63
+ <div
64
+ data-testid="promo"
65
+ className={tone === 'dark' ? 'bg-black' : 'bg-white'}
66
+ {...partAttrs('shell')}
67
+ >
68
+ <ThemeEditable name="art">
69
+ <img alt="Promo art" src="/default.jpg" />
70
+ </ThemeEditable>
71
+ <ThemeEditable name="headline">
72
+ <h2>New season</h2>
73
+ </ThemeEditable>
74
+ <ThemeSlot />
75
+ </div>
76
+ );
77
+ }
78
+
79
+ /** Registered without `defineCustomBlock`, which the barrel also has to accept. */
80
+ const BestSellers = () => <div data-testid="best-sellers" />;
81
+
82
+ /**
83
+ * The two ways a field ends up with no `field-<key>` element of its own: a
84
+ * link's destination rides on the one `<a>` its label already claimed, and a
85
+ * developer who wrote their own `data-part` keeps it.
86
+ */
87
+ function LinkCard() {
88
+ return (
89
+ <div>
90
+ <ThemeEditable name="cta">
91
+ <a href="/sale">Shop now</a>
92
+ </ThemeEditable>
93
+ <ThemeEditable name="note">
94
+ <p data-part="footnote">Ends Friday</p>
95
+ </ThemeEditable>
96
+ </div>
97
+ );
98
+ }
99
+
100
+ /**
101
+ * The multi-slot shape end to end: three regions, one of them styled through a
102
+ * declared part. Written as a brand would write it, so the whole path is
103
+ * exercised — declaration, the slot names threaded through the host, and the
104
+ * slot nodes read back out of the composition.
105
+ */
106
+ function MegaMenu() {
107
+ return (
108
+ <div>
109
+ <div {...partAttrs('row')}>
110
+ <ThemeSlot name="promo" label="Promo" />
111
+ </div>
112
+ <ThemeSlot name="links" label="Links" />
113
+ <ThemeSlot name="footer" label="Footer" />
114
+ </div>
115
+ );
116
+ }
117
+
118
+ const PROMO = 'custom:acme:promo-strip';
119
+ const LINK_CARD = 'custom:acme:link-card';
120
+ const MEGA_MENU = 'custom:acme:mega-menu';
121
+
122
+ const slotNode = (name: string, childId: string) => ({
123
+ id: `slot-${name}`,
124
+ type: CUSTOM_SLOT_BLOCK_TYPE,
125
+ label: name,
126
+ properties: { [SLOT_NAME_PROPERTY]: name },
127
+ styles: {},
128
+ order: 0,
129
+ hidden: false,
130
+ blocks: [
131
+ {
132
+ id: childId,
133
+ type: 'text',
134
+ label: childId,
135
+ properties: {},
136
+ styles: {},
137
+ order: 0,
138
+ hidden: false
139
+ }
140
+ ]
141
+ });
142
+
143
+ const props = (over: Partial<BlockRendererProps> = {}): BlockRendererProps => ({
144
+ block: {
145
+ id: 'b1',
146
+ type: PROMO,
147
+ label: 'Promo',
148
+ properties: {},
149
+ styles: {},
150
+ order: 0,
151
+ hidden: false
152
+ },
153
+ placeholderId: 'header',
154
+ sectionId: 's1',
155
+ isDesigner: false,
156
+ ...over
157
+ });
158
+
159
+ const renderRegistered = (
160
+ type: string,
161
+ over: Partial<BlockRendererProps> = {}
162
+ ) => {
163
+ const Renderer = blockRendererRegistry.getRenderer(type);
164
+ if (!Renderer) throw new Error(`No renderer registered for ${type}`);
165
+
166
+ return render(<Renderer {...props(over)} />);
167
+ };
168
+
169
+ beforeEach(() => {
170
+ registerCustomBlocks('acme', {
171
+ 'promo-strip': defineCustomBlock(PromoStrip),
172
+ 'best-sellers': BestSellers,
173
+ 'link-card': LinkCard,
174
+ 'mega-menu': defineCustomBlock(MegaMenu, {
175
+ label: 'Mega menu',
176
+ slots: [
177
+ { name: 'promo', label: 'Promo' },
178
+ { name: 'links', label: 'Links' },
179
+ { name: 'footer', label: 'Footer' }
180
+ ],
181
+ parts: { row: { states: ['hover', 'open'] } }
182
+ })
183
+ });
184
+ });
185
+
186
+ afterEach(() => {
187
+ clearCustomRenderers();
188
+ resetCustomCatalog();
189
+ resetCustomCollection();
190
+ });
191
+
192
+ describe('the documented barrel', () => {
193
+ it('registers both entries under the vendor from the call, not the component name', () => {
194
+ // Minification renames `PromoStrip` to a single letter; the record key is
195
+ // the only source that survives a production build.
196
+ expect(blockRendererRegistry.hasRenderer(PROMO)).toBe(true);
197
+ expect(blockRendererRegistry.hasRenderer('custom:acme:best-sellers')).toBe(
198
+ true
199
+ );
200
+ expect(PROMO.startsWith(CUSTOM_TYPE_PREFIX)).toBe(true);
201
+ });
202
+
203
+ it('renders the merchant value, the authored default and the slot in one pass', () => {
204
+ renderRegistered(PROMO, {
205
+ block: {
206
+ ...props().block,
207
+ properties: {
208
+ tone: { desktop: 'dark' },
209
+ headline: { desktop: 'Autumn' }
210
+ },
211
+ blocks: [
212
+ {
213
+ id: 'c1',
214
+ type: 'text',
215
+ label: 'c1',
216
+ properties: {},
217
+ styles: {},
218
+ order: 0,
219
+ hidden: false
220
+ }
221
+ ]
222
+ }
223
+ });
224
+
225
+ // useThemeSetting -> the stored value.
226
+ expect(screen.getByTestId('promo')).toHaveClass('bg-black');
227
+ // ThemeEditable -> the stored value where one exists...
228
+ expect(screen.getByRole('heading')).toHaveTextContent('Autumn');
229
+ // ...and the authored JSX where none does.
230
+ expect(screen.getByRole('img')).toHaveAttribute('src', '/default.jpg');
231
+ // ThemeSlot -> block.blocks, through the shared child pipeline.
232
+ expect(screen.getByTestId('slot-child')).toHaveAttribute('data-id', 'c1');
233
+ });
234
+
235
+ it('leaves a developer-authored part alone and stamps its own on the fields', () => {
236
+ renderRegistered(PROMO);
237
+
238
+ expect(screen.getByTestId('promo')).toHaveAttribute('data-part', 'shell');
239
+ expect(screen.getByRole('heading')).toHaveAttribute(
240
+ 'data-part',
241
+ 'field-headline'
242
+ );
243
+ });
244
+
245
+ it('collects a descriptor the editor could offer from a block that declared nothing', () => {
246
+ renderRegistered(PROMO);
247
+
248
+ const catalog = getCustomCatalog();
249
+ const descriptor = catalog?.blocks.find((entry) => entry.type === PROMO);
250
+
251
+ expect(catalog?.vendor).toBe('acme');
252
+ // The hook's key and both wrapped elements, from one render.
253
+ expect(descriptor?.settings.map((setting) => setting.key)).toEqual(
254
+ expect.arrayContaining(['tone', 'art', 'headline'])
255
+ );
256
+ // Every part the catalog advertises is one the DOM above actually carries.
257
+ expect(descriptor?.parts).toEqual(
258
+ expect.arrayContaining(['field-art', 'field-headline'])
259
+ );
260
+ // `<ThemeSlot />` alone makes the block droppable — no declaration needed.
261
+ expect(descriptor?.hasChildren).toBe(true);
262
+ });
263
+ });
264
+
265
+ describe('a block with several named slots', () => {
266
+ it('gives each slot its own node children and mixes none of them', () => {
267
+ const { container } = renderRegistered(MEGA_MENU, {
268
+ block: {
269
+ ...props().block,
270
+ type: MEGA_MENU,
271
+ blocks: [
272
+ slotNode('promo', 'in-promo'),
273
+ slotNode('links', 'in-links'),
274
+ slotNode('footer', 'in-footer')
275
+ ]
276
+ }
277
+ });
278
+
279
+ expect(
280
+ Array.from(container.querySelectorAll('[data-testid="slot-child"]')).map(
281
+ (node) => node.getAttribute('data-id')
282
+ )
283
+ ).toEqual(['in-promo', 'in-links', 'in-footer']);
284
+ });
285
+
286
+ it('renders the slots that do exist when the others have no node yet', () => {
287
+ // The state every composition starts in: the merchant has opened one
288
+ // region and not the others. Nothing crashes and nothing else appears.
289
+ const { container } = renderRegistered(MEGA_MENU, {
290
+ block: {
291
+ ...props().block,
292
+ type: MEGA_MENU,
293
+ blocks: [slotNode('links', 'in-links')]
294
+ }
295
+ });
296
+
297
+ expect(
298
+ Array.from(container.querySelectorAll('[data-testid="slot-child"]')).map(
299
+ (node) => node.getAttribute('data-id')
300
+ )
301
+ ).toEqual(['in-links']);
302
+ });
303
+
304
+ it('announces the slots and the declared part with its states', () => {
305
+ const descriptor = getCustomCatalog()?.blocks.find(
306
+ (entry) => entry.type === MEGA_MENU
307
+ );
308
+
309
+ expect(descriptor?.slots.map((slot) => slot.name)).toEqual([
310
+ 'promo',
311
+ 'links',
312
+ 'footer'
313
+ ]);
314
+ expect(descriptor?.parts).toEqual(['row']);
315
+ expect(descriptor?.partStates).toEqual({ row: ['hover', 'open'] });
316
+ });
317
+ });
318
+
319
+ describe('the advertised parts and the rendered DOM', () => {
320
+ const partsIn = (container: HTMLElement): string[] =>
321
+ Array.from(container.querySelectorAll('[data-part]')).map(
322
+ (node) => node.getAttribute('data-part') as string
323
+ );
324
+
325
+ const descriptorFor = (type: string) =>
326
+ getCustomCatalog()?.blocks.find((entry) => entry.type === type);
327
+
328
+ it('advertises exactly the field parts the promo strip renders', () => {
329
+ const { container } = renderRegistered(PROMO);
330
+
331
+ // Set equality, not containment. A part in the catalog that no node
332
+ // answers to is a control the editor offers with nothing behind it — the
333
+ // styles a merchant saves against it generate a selector that matches
334
+ // nothing, and the click that should open it never resolves.
335
+ expect(new Set(descriptorFor(PROMO)?.parts)).toEqual(
336
+ new Set(partsIn(container).filter((part) => part.startsWith('field-')))
337
+ );
338
+ });
339
+
340
+ it('advertises no part for a field with no element of its own', () => {
341
+ const { container } = renderRegistered(LINK_CARD, {
342
+ block: { ...props().block, type: LINK_CARD }
343
+ });
344
+
345
+ const descriptor = descriptorFor(LINK_CARD);
346
+
347
+ // Both fields reach the panel...
348
+ expect(descriptor?.settings.map((setting) => setting.key)).toEqual([
349
+ 'cta',
350
+ 'ctaHref',
351
+ 'note'
352
+ ]);
353
+ // ...and only the one element that carries `field-<key>` is advertised as
354
+ // a part. `ctaHref` shares the `<a>` with `cta`, and `note` gave its part
355
+ // up to the developer's own `footnote`.
356
+ expect(descriptor?.parts).toEqual(['field-cta']);
357
+ expect(partsIn(container)).toEqual(['field-cta', 'footnote']);
358
+ expect(new Set(descriptor?.parts)).toEqual(
359
+ new Set(partsIn(container).filter((part) => part.startsWith('field-')))
360
+ );
361
+ });
362
+ });
363
+
364
+ describe('the curated package root', () => {
365
+ /**
366
+ * Parsed from source rather than imported, exactly as
367
+ * `src/__tests__/registry-parity.test.ts` parses the registry — and here the
368
+ * reason is not weight but poison: `src/index.ts` re-exports
369
+ * `theme-placeholder`, which imports `server-only`, so the root entry throws
370
+ * "cannot be imported from a Client Component module" the moment a jsdom
371
+ * test (or a brand's client root) touches it. That constraint is why
372
+ * `@akinon/pz-theme/src/custom` is the documented path for block files, and
373
+ * it is also what makes the hand-written list in `src/index.ts` untestable
374
+ * by import: a renamed symbol there compiles fine and fails at the brand.
375
+ */
376
+ const rootSource = fs.readFileSync(
377
+ path.resolve(__dirname, '../../index.ts'),
378
+ 'utf8'
379
+ );
380
+
381
+ const barrelSource = fs.readFileSync(
382
+ path.resolve(__dirname, '../index.ts'),
383
+ 'utf8'
384
+ );
385
+
386
+ /** The names inside every `export { … } from '…'` clause a pattern selects. */
387
+ const exportedNames = (source: string, clause: RegExp): string[] =>
388
+ Array.from(source.matchAll(clause))
389
+ .flatMap((match) => match[1].split(','))
390
+ .map((name) => name.trim())
391
+ .filter(Boolean);
392
+
393
+ // `export type {` cannot match the runtime pattern: it requires `export {`
394
+ // with no word between, so the two lists stay disjoint.
395
+ const RUNTIME_FROM_CUSTOM = /export \{([^}]*)\} from '\.\/custom';/g;
396
+ const TYPES_FROM_CUSTOM = /export type \{([^}]*)\} from '\.\/custom';/g;
397
+ const ANY_TYPE_CLAUSE = /export type \{([^}]*)\} from '[^']*';/g;
398
+
399
+ it('names only bindings the custom barrel actually exports', () => {
400
+ const names = exportedNames(rootSource, RUNTIME_FROM_CUSTOM);
401
+
402
+ expect(names.length).toBeGreaterThan(0);
403
+ names.forEach((name) => {
404
+ expect(Object.keys(customBarrel)).toContain(name);
405
+ });
406
+ });
407
+
408
+ it('names only types the custom barrel actually exports', () => {
409
+ const rootTypes = exportedNames(rootSource, TYPES_FROM_CUSTOM);
410
+ const barrelTypes = exportedNames(barrelSource, ANY_TYPE_CLAUSE);
411
+
412
+ // Source-level because a type has no runtime binding to compare — the
413
+ // erased half of the surface would otherwise go completely unguarded.
414
+ expect(rootTypes.length).toBeGreaterThan(0);
415
+ expect(barrelTypes).toEqual(expect.arrayContaining(rootTypes));
416
+ });
417
+
418
+ it('does not publish partAttrs twice from one entry point', () => {
419
+ // `export * from './utils'` already puts it on the root. The custom barrel
420
+ // re-exports it so a block file needs one import; naming it in the root
421
+ // block too would be a second spelling of the same symbol.
422
+ expect(exportedNames(rootSource, RUNTIME_FROM_CUSTOM)).not.toContain(
423
+ 'partAttrs'
424
+ );
425
+ expect(customBarrel.partAttrs).toBe(partAttrs);
426
+ });
427
+ });