@adobe/helix-md2jcr 1.3.0 → 1.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.
@@ -0,0 +1,969 @@
1
+ # Section Style Options (Element Grouping for `style`) Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Bring PR #215's `classes`/`classes_*` element-grouping for block options to sections, so a section model's `style`/`style_*` group is reverse-mapped from the single Section Metadata `style` cell into the correct JCR attributes.
6
+
7
+ **Architecture:** Generalize the routing helpers from PR #215 (currently hardcoded to the `classes` group and living inside `grid-table.js`) into a shared, group-name-parameterized module. Blocks keep calling them with `'classes'`; the section helper calls them with `'style'`, fed by the tokenized value of the single well-known `style` Section Metadata cell. The block path already alphabetically sorts its output attributes, so generalizing the helper's result ordering is safe for blocks; sections emit attributes in model-field order for deterministic, readable XML.
8
+
9
+ **Tech Stack:** Node ESM, Handlebars helpers/partials, mdast (remark gridtables), Mocha + Chai/assert golden-file fixtures (`*.md` -> `*.xml`), c8.
10
+
11
+ ---
12
+
13
+ ## Background / Why
14
+
15
+ Per the AEM spec (markup-sections-blocks): *"the only well-known section metadata property is **Style** which will be turned into additional CSS classes added to the containing section element."* `style` is therefore the section analog of a block's `classes`. PR #215 implemented element grouping for `classes`; this plan implements the identical concept for the `style` group on sections.
16
+
17
+ **Current gap** (`src/mdast2jcr/hb/helpers/section-helper.js`): `processMetadataRows` maps each metadata row to a model field by exact name (`getField(model, key)`) and silently drops keys with no field. A `style_*` field never matches the single `style` row, so grouped section style options cannot be reverse-mapped. The distribution logic (`collectOptionValues`, `distributeBlockOptions`, `isClassesField`) lives entirely in `grid-table.js`/`Models.js` and is hardcoded to `classes`; the section helper does not use it.
18
+
19
+ ## Design decisions (locked)
20
+
21
+ 1. **Reuse strategy:** Generalize the helpers by group name (one shared implementation) — not a section-local duplicate.
22
+ 2. **Token source:** The single, well-known `style` Section Metadata cell (comma-separated), routed across `style` + `style_*` fields. Per-field rows are not the canonical authored form (a directly authored `style_x` row would still be set by the normal row path, but fixtures use only the collapsed `style` cell).
23
+ 3. **`modelFields` asymmetry (intentional):** For blocks, the `classes` group is excluded from `modelFields` (block options, not content). For sections, the `style` group **stays in** `modelFields` — the Universal Editor needs those fields to render the section form, and existing section fixtures already include `style`. `getModelFieldNames` is therefore left unchanged (it excludes only the `classes` group).
24
+ 4. **Trigger gate:** Section style distribution runs only when the model declares at least one grouped field whose name starts with `style_`. A section model with only a base `style` field keeps the existing per-row behavior (no change to existing fixtures, including the `multiselect` `[a,b]` formatting).
25
+ 5. **Case sensitivity:** Row keys are matched case-sensitively against lowercase field names (`style`), consistent with all existing fixtures and `getField`. Not changing this here.
26
+
27
+ ## File Structure
28
+
29
+ - **Create** `src/mdast2jcr/domain/GroupOptions.js` — pure routing helpers `collectOptionValues` and `distributeGroupOptions(model, tokens, group)`. One responsibility: turn a flat token list into a `{ fieldName: value }` map for one element-grouping group. No mdast/Handlebars deps; imports only `isGroupField` from `Models.js`.
30
+ - **Modify** `src/mdast2jcr/domain/Models.js` — add `isGroupField(name, group)`; redefine `isClassesField` in terms of it; export `isGroupField`.
31
+ - **Modify** `src/mdast2jcr/hb/partials/grid-table.js` — delete the local `collectOptionValues`/`distributeBlockOptions`; import `distributeGroupOptions` from `GroupOptions.js`; update the two call sites to pass `'classes'`.
32
+ - **Modify** `src/mdast2jcr/hb/helpers/section-helper.js` — import `distributeGroupOptions`; in `processMetadataRows`, when the model has a `style_*` field, distribute the `style` cell's tokens across the style group.
33
+ - **Create** fixtures under `test/fixtures/blocks/core/section-style-options/` — four scenarios mirroring the block-options suite.
34
+ - **Modify** `test/block.test.js` — add a `section style options` describe block with four `it`s.
35
+ - **Modify** `test/models.test.js` — unit tests for `isGroupField`.
36
+ - **Create** `test/group.options.test.js` — unit tests for `distributeGroupOptions`.
37
+ - **Create** `docs/section-styles.md` and **modify** `README.md` — document the feature.
38
+
39
+ ## How to run tests
40
+
41
+ - Full suite (144+ tests): `npm test`
42
+ - A single file: `npx mocha test/<file>.test.js` (mocha config auto-loads `test/setup-env.js`)
43
+
44
+ ---
45
+
46
+ ### Task 1: Add `isGroupField` to `Models.js`
47
+
48
+ **Files:**
49
+ - Modify: `src/mdast2jcr/domain/Models.js`
50
+ - Test: `test/models.test.js`
51
+
52
+ - [ ] **Step 1: Write the failing test**
53
+
54
+ Add to `test/models.test.js` a new `describe` inside the top-level `describe('Models Utility Test', ...)` block, and add `isGroupField` to the import from `../src/mdast2jcr/domain/Models.js`.
55
+
56
+ Update the import:
57
+
58
+ ```js
59
+ import {
60
+ findModelById,
61
+ getField,
62
+ getModelFieldNames,
63
+ isGroupField,
64
+ } from '../src/mdast2jcr/domain/Models.js';
65
+ ```
66
+
67
+ Add the test:
68
+
69
+ ```js
70
+ describe('isGroupField', () => {
71
+ it('matches the base group name and its underscored options', () => {
72
+ assert.strictEqual(isGroupField('classes', 'classes'), true);
73
+ assert.strictEqual(isGroupField('classes_background', 'classes'), true);
74
+ assert.strictEqual(isGroupField('style', 'style'), true);
75
+ assert.strictEqual(isGroupField('style_fullwidth', 'style'), true);
76
+ });
77
+
78
+ it('does not match other fields or a different group', () => {
79
+ assert.strictEqual(isGroupField('title', 'classes'), false);
80
+ assert.strictEqual(isGroupField('style', 'classes'), false);
81
+ assert.strictEqual(isGroupField('stylebox', 'style'), false);
82
+ });
83
+ });
84
+ ```
85
+
86
+ - [ ] **Step 2: Run test to verify it fails**
87
+
88
+ Run: `npx mocha test/models.test.js`
89
+ Expected: FAIL — `isGroupField is not a function` (import is `undefined`).
90
+
91
+ - [ ] **Step 3: Implement `isGroupField` and rebase `isClassesField` on it**
92
+
93
+ In `src/mdast2jcr/domain/Models.js`, replace the existing `isClassesField` definition (currently lines 44-53) with:
94
+
95
+ ```js
96
+ /**
97
+ * Returns true if the field name belongs to an element-grouping group — either
98
+ * the base field named exactly `group`, or a grouped option field `group_<name>`
99
+ * (element grouping for block options / section styles).
100
+ * @param {string} name The field name.
101
+ * @param {string} group The group name (e.g. `classes` or `style`).
102
+ * @return {boolean}
103
+ */
104
+ function isGroupField(name, group) {
105
+ return name === group || name.startsWith(`${group}_`);
106
+ }
107
+
108
+ /**
109
+ * Returns true if the field name belongs to the `classes` block-options group —
110
+ * either the base `classes` field or a grouped `classes_*` option field
111
+ * (element grouping for block options).
112
+ * @param {string} name The field name.
113
+ * @return {boolean}
114
+ */
115
+ function isClassesField(name) {
116
+ return isGroupField(name, 'classes');
117
+ }
118
+ ```
119
+
120
+ Then add `isGroupField` to the export block at the bottom of the file:
121
+
122
+ ```js
123
+ export {
124
+ getField,
125
+ findModelById,
126
+ getModelFieldNames,
127
+ isClassesField,
128
+ isGroupField,
129
+ };
130
+ ```
131
+
132
+ - [ ] **Step 4: Run test to verify it passes**
133
+
134
+ Run: `npx mocha test/models.test.js`
135
+ Expected: PASS (all existing Models tests + the two new `isGroupField` tests).
136
+
137
+ - [ ] **Step 5: Commit**
138
+
139
+ ```bash
140
+ git add src/mdast2jcr/domain/Models.js test/models.test.js
141
+ git commit -m "feat(models): add group-name-parameterized isGroupField helper
142
+
143
+ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
144
+ ```
145
+
146
+ ---
147
+
148
+ ### Task 2: Create the shared `GroupOptions.js` module
149
+
150
+ **Files:**
151
+ - Create: `src/mdast2jcr/domain/GroupOptions.js`
152
+ - Test: `test/group.options.test.js`
153
+
154
+ This moves `collectOptionValues` and the generalized `distributeGroupOptions` out of `grid-table.js`. Two behavior notes vs. the original `distributeBlockOptions`: (a) it takes a `group` parameter (default `'classes'`); (b) it emits the result in **model-field order** (the original emitted in match order). Block output is alphabetically sorted downstream by `sortJcrProperties`, so (b) does not change any block fixture; it gives sections clean, deterministic ordering.
155
+
156
+ - [ ] **Step 1: Write the failing test**
157
+
158
+ Create `test/group.options.test.js`:
159
+
160
+ ```js
161
+ /*
162
+ * Copyright 2025 Adobe. All rights reserved.
163
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
164
+ * you may not use this file except in compliance with the License. You may obtain a copy
165
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
166
+ *
167
+ * Unless required by applicable law or agreed to in writing, software distributed under
168
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
169
+ * OF ANY KIND, either express or implied. See the License for the specific language
170
+ * governing permissions and limitations under the License.
171
+ */
172
+ /* eslint-env mocha */
173
+
174
+ import assert from 'assert';
175
+ import { distributeGroupOptions } from '../src/mdast2jcr/domain/GroupOptions.js';
176
+
177
+ describe('GroupOptions Utility Test', () => {
178
+ describe('distributeGroupOptions', () => {
179
+ it('routes select + select + boolean for the style group', () => {
180
+ const model = {
181
+ fields: [
182
+ { component: 'text', name: 'name' },
183
+ { component: 'select', name: 'style', options: [{ value: 'variant-a' }, { value: 'variant-b' }] },
184
+ { component: 'select', name: 'style_background', options: [{ value: 'light' }, { value: 'dark' }] },
185
+ { component: 'boolean', name: 'style_fullwidth' },
186
+ ],
187
+ };
188
+ const result = distributeGroupOptions(model, ['variant-a', 'light', 'fullwidth'], 'style');
189
+ assert.deepStrictEqual(result, {
190
+ style: 'variant-a',
191
+ style_background: 'light',
192
+ style_fullwidth: 'true',
193
+ });
194
+ });
195
+
196
+ it('lets a multi field claim every matching token (space after comma)', () => {
197
+ const model = {
198
+ fields: [
199
+ { component: 'select', name: 'style', options: [{ value: 'variant-a' }, { value: 'variant-b' }] },
200
+ { component: 'multiselect', name: 'style_background', multi: true, options: [{ value: 'light' }, { value: 'dark' }, { value: 'muted' }] },
201
+ { component: 'boolean', name: 'style_fullwidth' },
202
+ ],
203
+ };
204
+ const result = distributeGroupOptions(model, ['variant-b', 'light', 'muted'], 'style');
205
+ assert.deepStrictEqual(result, {
206
+ style: 'variant-b',
207
+ style_background: '[light, muted]',
208
+ });
209
+ });
210
+
211
+ it('falls back leftover tokens to the free-form base field', () => {
212
+ const model = {
213
+ fields: [
214
+ { component: 'text', name: 'style', multi: true },
215
+ { component: 'boolean', name: 'style_sticky' },
216
+ ],
217
+ };
218
+ const result = distributeGroupOptions(model, ['highlight', 'featured', 'sticky'], 'style');
219
+ assert.deepStrictEqual(result, {
220
+ style: '[highlight, featured]',
221
+ style_sticky: 'true',
222
+ });
223
+ });
224
+
225
+ it('emits only matched booleans, in model-field order', () => {
226
+ const model = {
227
+ fields: [
228
+ { component: 'boolean', name: 'style_dark' },
229
+ { component: 'boolean', name: 'style_fullwidth' },
230
+ { component: 'boolean', name: 'style_narrow' },
231
+ ],
232
+ };
233
+ const result = distributeGroupOptions(model, ['dark', 'narrow'], 'style');
234
+ assert.deepStrictEqual(result, {
235
+ style_dark: 'true',
236
+ style_narrow: 'true',
237
+ });
238
+ });
239
+
240
+ it('defaults to the classes group and returns {} when nothing matches', () => {
241
+ const model = { fields: [{ component: 'select', name: 'classes', options: [{ value: 'a' }] }] };
242
+ assert.deepStrictEqual(distributeGroupOptions(model, ['a']), { classes: 'a' });
243
+ assert.deepStrictEqual(distributeGroupOptions(model, []), {});
244
+ assert.deepStrictEqual(distributeGroupOptions({ fields: [] }, ['a'], 'style'), {});
245
+ });
246
+ });
247
+ });
248
+ ```
249
+
250
+ - [ ] **Step 2: Run test to verify it fails**
251
+
252
+ Run: `npx mocha test/group.options.test.js`
253
+ Expected: FAIL — cannot find module `../src/mdast2jcr/domain/GroupOptions.js`.
254
+
255
+ - [ ] **Step 3: Create the module**
256
+
257
+ Create `src/mdast2jcr/domain/GroupOptions.js`:
258
+
259
+ ```js
260
+ /*
261
+ * Copyright 2025 Adobe. All rights reserved.
262
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
263
+ * you may not use this file except in compliance with the License. You may obtain a copy
264
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
265
+ *
266
+ * Unless required by applicable law or agreed to in writing, software distributed under
267
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
268
+ * OF ANY KIND, either express or implied. See the License for the specific language
269
+ * governing permissions and limitations under the License.
270
+ */
271
+ import { isGroupField } from './Models.js';
272
+
273
+ /**
274
+ * @typedef {import('../index.d.ts').ModelDef} Model
275
+ */
276
+
277
+ /**
278
+ * Recursively collect every option `value` from a field's options tree
279
+ * (options may be nested under `children`).
280
+ * @param {Array<object>} options - The field's options array.
281
+ * @return {Array<string>} The flat list of option values.
282
+ */
283
+ function collectOptionValues(options) {
284
+ return (options || []).flatMap((option) => {
285
+ if (option.children) {
286
+ return collectOptionValues(option.children);
287
+ }
288
+ return option.value !== undefined ? [option.value] : [];
289
+ });
290
+ }
291
+
292
+ /**
293
+ * Distribute element-grouping tokens (a block header's classes, a container
294
+ * child's leading cell, or a section's `style` cell) across the model's group
295
+ * fields, per the AEM "element grouping for block options" rules:
296
+ *
297
+ * - a boolean field receives "true" when its name suffix (after `${group}_`)
298
+ * appears as a token (e.g. token "fullwidth" -> `${group}_fullwidth`="true");
299
+ * - a text/select field with options claims the tokens that match its option
300
+ * values (e.g. token "light" -> `${group}_background`="light");
301
+ * - any remaining tokens fall back to the base `${group}` field, which also
302
+ * covers the common single-field case (a free-form `${group}` field).
303
+ *
304
+ * Multi fields are written as `[a, b]`; single fields as a comma-separated list.
305
+ * The result is emitted in model-field order so callers that do not sort
306
+ * (e.g. sections) get deterministic, readable output.
307
+ * @param {Model} model - The block, item, or section model.
308
+ * @param {Array<string>} tokens - The option tokens.
309
+ * @param {string} [group] - The group name (defaults to `classes`).
310
+ * @return {Object<string, string>} A map of group field name to property value.
311
+ */
312
+ function distributeGroupOptions(model, tokens, group = 'classes') {
313
+ const groupFields = (model?.fields || []).filter((f) => isGroupField(f.name, group));
314
+ if (groupFields.length === 0 || tokens.length === 0) {
315
+ return {};
316
+ }
317
+
318
+ const suffixRegExp = new RegExp(`^${group}_`);
319
+ const remaining = [...tokens];
320
+ const assigned = new Map();
321
+
322
+ // boolean fields: the suffix after `${group}_` becomes the option when present
323
+ groupFields
324
+ .filter((field) => field.component === 'boolean')
325
+ .forEach((field) => {
326
+ const suffix = field.name.replace(suffixRegExp, '');
327
+ const idx = remaining.indexOf(suffix);
328
+ if (idx !== -1) {
329
+ assigned.set(field.name, true);
330
+ remaining.splice(idx, 1);
331
+ }
332
+ });
333
+
334
+ // text/select fields with options: claim the tokens that match their values
335
+ groupFields
336
+ .filter((field) => field.component !== 'boolean' && field.options)
337
+ .forEach((field) => {
338
+ const values = collectOptionValues(field.options);
339
+ const matched = remaining.filter((t) => values.includes(t));
340
+ if (matched.length > 0) {
341
+ assigned.set(field.name, matched);
342
+ matched.forEach((m) => remaining.splice(remaining.indexOf(m), 1));
343
+ }
344
+ });
345
+
346
+ // leftover tokens fall back to the base `${group}` field (and the single-field case)
347
+ const base = groupFields.find((f) => f.name === group && f.component !== 'boolean');
348
+ if (remaining.length > 0 && base) {
349
+ assigned.set(group, [...(assigned.get(group) || []), ...remaining]);
350
+ }
351
+
352
+ // emit in model-field order for deterministic, readable output
353
+ const result = {};
354
+ groupFields.forEach((field) => {
355
+ if (!assigned.has(field.name)) {
356
+ return;
357
+ }
358
+ const value = assigned.get(field.name);
359
+ if (value === true) {
360
+ result[field.name] = 'true';
361
+ } else {
362
+ result[field.name] = field.multi === true ? `[${value.join(', ')}]` : value.join(', ');
363
+ }
364
+ });
365
+ return result;
366
+ }
367
+
368
+ export {
369
+ collectOptionValues,
370
+ distributeGroupOptions,
371
+ };
372
+ ```
373
+
374
+ - [ ] **Step 4: Run test to verify it passes**
375
+
376
+ Run: `npx mocha test/group.options.test.js`
377
+ Expected: PASS (all five `distributeGroupOptions` tests).
378
+
379
+ - [ ] **Step 5: Commit**
380
+
381
+ ```bash
382
+ git add src/mdast2jcr/domain/GroupOptions.js test/group.options.test.js
383
+ git commit -m "feat(group-options): extract group-parameterized distribution helper
384
+
385
+ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
386
+ ```
387
+
388
+ ---
389
+
390
+ ### Task 3: Wire `grid-table.js` to the shared module (no behavior change for blocks)
391
+
392
+ **Files:**
393
+ - Modify: `src/mdast2jcr/hb/partials/grid-table.js`
394
+ - Test (regression): `test/block.test.js`
395
+
396
+ - [ ] **Step 1: Confirm the block-options baseline is green**
397
+
398
+ Run: `npx mocha test/block.test.js`
399
+ Expected: PASS. This is the regression guard for this task — output must be identical after the refactor.
400
+
401
+ - [ ] **Step 2: Delete the local helpers from `grid-table.js`**
402
+
403
+ Remove the entire block from line 41 through line 124 inclusive — the `// -- Block Options (classes) ...` comment, the `collectOptionValues` function, and the `distributeBlockOptions` function. Leave the following `// -- Block Header ...` section intact.
404
+
405
+ - [ ] **Step 3: Import `distributeGroupOptions` from the shared module**
406
+
407
+ Add the import after the existing `Models.js` import (currently lines 20-23):
408
+
409
+ ```js
410
+ import {
411
+ findModelById,
412
+ getModelFieldNames, isClassesField,
413
+ } from '../../domain/Models.js';
414
+ import { distributeGroupOptions } from '../../domain/GroupOptions.js';
415
+ ```
416
+
417
+ (`isClassesField` is still imported — it is used at the container child-item gate.)
418
+
419
+ - [ ] **Step 4: Update the two call sites to pass `'classes'`**
420
+
421
+ In `extractBlockHeaderProperties` (was line 196):
422
+
423
+ ```js
424
+ if (model) {
425
+ Object.assign(props, distributeGroupOptions(model, blockDetails.classes, 'classes'));
426
+ }
427
+ ```
428
+
429
+ In the `blockItem` branch (was line 449):
430
+
431
+ ```js
432
+ // distribute the options across the model's classes group fields
433
+ Object.assign(properties, distributeGroupOptions(model, classes, 'classes'));
434
+ ```
435
+
436
+ - [ ] **Step 5: Run the block tests + lint to verify no regression**
437
+
438
+ Run: `npx mocha test/block.test.js && npx eslint src/mdast2jcr/hb/partials/grid-table.js src/mdast2jcr/domain/GroupOptions.js`
439
+ Expected: PASS, no lint errors. All block-options fixtures (`block-options-grouping`, `-multi`, `-fallback`, `-booleans`) and container-block fixtures unchanged.
440
+
441
+ - [ ] **Step 6: Commit**
442
+
443
+ ```bash
444
+ git add src/mdast2jcr/hb/partials/grid-table.js
445
+ git commit -m "refactor(grid-table): use shared distributeGroupOptions for classes
446
+
447
+ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
448
+ ```
449
+
450
+ ---
451
+
452
+ ### Task 4: Distribute the section `style` group in `section-helper.js`
453
+
454
+ **Files:**
455
+ - Modify: `src/mdast2jcr/hb/helpers/section-helper.js`
456
+ - Test: deferred to Task 5 (golden-file fixtures exercise this end-to-end)
457
+
458
+ - [ ] **Step 1: Import the shared helper**
459
+
460
+ In `src/mdast2jcr/hb/helpers/section-helper.js`, add after the existing `Models.js` import (currently lines 16-20):
461
+
462
+ ```js
463
+ import {
464
+ findModelById,
465
+ getField,
466
+ getModelFieldNames,
467
+ } from '../../domain/Models.js';
468
+ import { distributeGroupOptions } from '../../domain/GroupOptions.js';
469
+ ```
470
+
471
+ - [ ] **Step 2: Distribute the `style` cell in `processMetadataRows`**
472
+
473
+ Replace the body of `processMetadataRows` (currently lines 58-97) with:
474
+
475
+ ```js
476
+ function processMetadataRows(rows, model) {
477
+ const attributes = {};
478
+
479
+ // always push the model fields so UE can use them, even if no attributes are found
480
+ const modelFields = getModelFieldNames(model);
481
+
482
+ // a section uses element grouping when it declares a `style_*` field; in that
483
+ // case the single well-known `style` cell collapses the whole style group and
484
+ // must be distributed back across style / style_* fields. A model with only a
485
+ // base `style` field keeps the simple per-row mapping below.
486
+ const hasStyleGroup = (model?.fields || []).some((f) => f.name.startsWith('style_'));
487
+
488
+ for (const row of rows) {
489
+ const cells = findAll(row, (n) => n.type === 'gtCell', true);
490
+ if (cells.length < 2) {
491
+ // eslint-disable-next-line no-continue
492
+ continue;
493
+ }
494
+
495
+ const key = toString(cells[0])?.trim();
496
+ const value = toString(cells[1])?.trim();
497
+
498
+ // Skip empty keys/values and the special blockModelId field
499
+ if (!key || !value || key === 'blockModelId') {
500
+ // eslint-disable-next-line no-continue
501
+ continue;
502
+ }
503
+
504
+ // element grouping for section styles: route the flat `style` cell across
505
+ // the model's style group fields (boolean by name suffix, select/multiselect
506
+ // by declared options, leftovers to the base `style` field)
507
+ if (hasStyleGroup && key === 'style') {
508
+ Object.assign(
509
+ attributes,
510
+ distributeGroupOptions(
511
+ model,
512
+ value.split(',').map((s) => s.trim()).filter(Boolean),
513
+ 'style',
514
+ ),
515
+ );
516
+ // eslint-disable-next-line no-continue
517
+ continue;
518
+ }
519
+
520
+ const field = getField(model, key);
521
+ if (!field) {
522
+ // eslint-disable-next-line no-continue
523
+ continue;
524
+ }
525
+
526
+ if (field.component === 'multiselect') {
527
+ const multiValue = value.split(',')
528
+ .map((s) => s.trim())
529
+ .filter(Boolean);
530
+ attributes[key] = `[${multiValue.join(',')}]`;
531
+ } else {
532
+ attributes[key] = value;
533
+ }
534
+ }
535
+
536
+ return { attributes, modelFields };
537
+ }
538
+ ```
539
+
540
+ - [ ] **Step 3: Sanity-check existing section tests still pass**
541
+
542
+ Run: `npx mocha test/block.test.js --grep "section-metadata"`
543
+ Expected: PASS. The existing `section-metadata` and `section-metadata-custom` fixtures use models with no `style_*` field, so `hasStyleGroup` is `false` and their output is unchanged.
544
+
545
+ - [ ] **Step 4: Lint**
546
+
547
+ Run: `npx eslint src/mdast2jcr/hb/helpers/section-helper.js`
548
+ Expected: no errors.
549
+
550
+ - [ ] **Step 5: Commit**
551
+
552
+ ```bash
553
+ git add src/mdast2jcr/hb/helpers/section-helper.js
554
+ git commit -m "feat(section-metadata): distribute style group via element grouping
555
+
556
+ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
557
+ ```
558
+
559
+ ---
560
+
561
+ ### Task 5: Golden-file fixtures + `section style options` test suite
562
+
563
+ **Files:**
564
+ - Create: `test/fixtures/blocks/core/section-style-options/section-style-grouping/{_section-style-grouping.json,section-style-grouping.md,section-style-grouping.xml}`
565
+ - Create: `test/fixtures/blocks/core/section-style-options/section-style-grouping-multi/{_...json,...md,...xml}`
566
+ - Create: `test/fixtures/blocks/core/section-style-options/section-style-grouping-fallback/{_...json,...md,...xml}`
567
+ - Create: `test/fixtures/blocks/core/section-style-options/section-style-grouping-booleans/{_...json,...md,...xml}`
568
+ - Modify: `test/block.test.js`
569
+
570
+ Each fixture defines the global `section` model (no `blockModelId` needed) plus a single section containing a heading and a Section Metadata table. The `_<spec>.json` consolidated format (`{ definitions, models, filters }`) is loaded by `loadBlockResources`.
571
+
572
+ - [ ] **Step 1: Write the failing test wiring**
573
+
574
+ In `test/block.test.js`, add a new top-level describe immediately after the `describe('block options', ...)` block (which ends at line 337, just before `describe('cust-x', ...)`):
575
+
576
+ ```js
577
+ /**
578
+ * Section style options via element grouping. A section model exposes its CSS
579
+ * classes through a `style` group (the base `style` field plus any `style_*`
580
+ * field). The single well-known Section Metadata `style` cell collapses the
581
+ * whole group; md2jcr routes each token back to its field — select/multiselect
582
+ * by declared options, boolean by name suffix — with leftovers falling back to
583
+ * the base `style` field. Unlike a block's `classes` group, the section `style`
584
+ * group stays in modelFields (the UE renders those fields).
585
+ */
586
+ describe('section style options', () => {
587
+ const folder = 'blocks/core/section-style-options';
588
+
589
+ // Single selects + a boolean that is on.
590
+ it('section-style-grouping', async () => {
591
+ await testBlock('section-style-grouping', `${folder}/section-style-grouping`);
592
+ });
593
+
594
+ // A multiselect claims several values; a boolean that is off contributes nothing.
595
+ it('section-style-grouping-multi', async () => {
596
+ await testBlock('section-style-grouping-multi', `${folder}/section-style-grouping-multi`);
597
+ });
598
+
599
+ // Free-form base `style` catches leftover tokens alongside a grouped boolean.
600
+ it('section-style-grouping-fallback', async () => {
601
+ await testBlock('section-style-grouping-fallback', `${folder}/section-style-grouping-fallback`);
602
+ });
603
+
604
+ // Several independent booleans: each is "true" only when its suffix appears.
605
+ it('section-style-grouping-booleans', async () => {
606
+ await testBlock('section-style-grouping-booleans', `${folder}/section-style-grouping-booleans`);
607
+ });
608
+ });
609
+ ```
610
+
611
+ - [ ] **Step 2: Run to verify it fails**
612
+
613
+ Run: `npx mocha test/block.test.js --grep "section style options"`
614
+ Expected: FAIL — fixtures do not exist (ENOENT reading the `.md`/`.json`).
615
+
616
+ - [ ] **Step 3: Create fixture 1 — `section-style-grouping` (select + select + boolean)**
617
+
618
+ `test/fixtures/blocks/core/section-style-options/section-style-grouping/_section-style-grouping.json`:
619
+
620
+ ```json
621
+ {
622
+ "definitions": [],
623
+ "models": [
624
+ {
625
+ "id": "section",
626
+ "fields": [
627
+ { "component": "text", "name": "name", "label": "Section Name" },
628
+ { "component": "select", "name": "style", "label": "Variant", "options": [ { "name": "Variant A", "value": "variant-a" }, { "name": "Variant B", "value": "variant-b" } ] },
629
+ { "component": "select", "name": "style_background", "label": "Background", "options": [ { "name": "Light", "value": "light" }, { "name": "Dark", "value": "dark" } ] },
630
+ { "component": "boolean", "name": "style_fullwidth", "label": "Full width" }
631
+ ]
632
+ }
633
+ ],
634
+ "filters": []
635
+ }
636
+ ```
637
+
638
+ `test/fixtures/blocks/core/section-style-options/section-style-grouping/section-style-grouping.md`:
639
+
640
+ ```markdown
641
+ # Section Title
642
+
643
+ +-------------------------------------------------------+
644
+ | Section Metadata |
645
+ +================+======================================+
646
+ | style | variant-a, light, fullwidth |
647
+ +----------------+--------------------------------------+
648
+ ```
649
+
650
+ `test/fixtures/blocks/core/section-style-options/section-style-grouping/section-style-grouping.xml`:
651
+
652
+ ```xml
653
+ <?xml version="1.0" encoding="UTF-8"?>
654
+ <jcr:root xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:nt="http://www.jcp.org/jcr/nt/1.0" xmlns:cq="http://www.day.com/jcr/cq/1.0" xmlns:sling="http://sling.apache.org/jcr/sling/1.0" jcr:primaryType="cq:Page">
655
+ <jcr:content cq:template="/libs/core/franklin/templates/page" sling:resourceType="core/franklin/components/page/v1/page" jcr:primaryType="cq:PageContent">
656
+ <root jcr:primaryType="nt:unstructured" sling:resourceType="core/franklin/components/root/v1/root">
657
+ <section sling:resourceType="core/franklin/components/section/v1/section" jcr:primaryType="nt:unstructured" style="variant-a" style_background="light" style_fullwidth="true" model="section" modelFields="[name,style,style_background,style_fullwidth]">
658
+ <title sling:resourceType="core/franklin/components/title/v1/title" jcr:primaryType="nt:unstructured" title="Section Title" titleType="h1"/>
659
+ </section>
660
+ </root>
661
+ </jcr:content>
662
+ </jcr:root>
663
+ ```
664
+
665
+ - [ ] **Step 4: Create fixture 2 — `section-style-grouping-multi` (multiselect claims several, boolean off)**
666
+
667
+ `_section-style-grouping-multi.json`:
668
+
669
+ ```json
670
+ {
671
+ "definitions": [],
672
+ "models": [
673
+ {
674
+ "id": "section",
675
+ "fields": [
676
+ { "component": "text", "name": "name" },
677
+ { "component": "select", "name": "style", "options": [ { "value": "variant-a" }, { "value": "variant-b" } ] },
678
+ { "component": "multiselect", "name": "style_background", "multi": true, "options": [ { "value": "light" }, { "value": "dark" }, { "value": "muted" } ] },
679
+ { "component": "boolean", "name": "style_fullwidth" }
680
+ ]
681
+ }
682
+ ],
683
+ "filters": []
684
+ }
685
+ ```
686
+
687
+ `section-style-grouping-multi.md`:
688
+
689
+ ```markdown
690
+ # Section Title
691
+
692
+ +-------------------------------------------------------+
693
+ | Section Metadata |
694
+ +================+======================================+
695
+ | style | variant-b, light, muted |
696
+ +----------------+--------------------------------------+
697
+ ```
698
+
699
+ `section-style-grouping-multi.xml`:
700
+
701
+ ```xml
702
+ <?xml version="1.0" encoding="UTF-8"?>
703
+ <jcr:root xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:nt="http://www.jcp.org/jcr/nt/1.0" xmlns:cq="http://www.day.com/jcr/cq/1.0" xmlns:sling="http://sling.apache.org/jcr/sling/1.0" jcr:primaryType="cq:Page">
704
+ <jcr:content cq:template="/libs/core/franklin/templates/page" sling:resourceType="core/franklin/components/page/v1/page" jcr:primaryType="cq:PageContent">
705
+ <root jcr:primaryType="nt:unstructured" sling:resourceType="core/franklin/components/root/v1/root">
706
+ <section sling:resourceType="core/franklin/components/section/v1/section" jcr:primaryType="nt:unstructured" style="variant-b" style_background="[light, muted]" model="section" modelFields="[name,style,style_background,style_fullwidth]">
707
+ <title sling:resourceType="core/franklin/components/title/v1/title" jcr:primaryType="nt:unstructured" title="Section Title" titleType="h1"/>
708
+ </section>
709
+ </root>
710
+ </jcr:content>
711
+ </jcr:root>
712
+ ```
713
+
714
+ - [ ] **Step 5: Create fixture 3 — `section-style-grouping-fallback` (free-form base + boolean)**
715
+
716
+ `_section-style-grouping-fallback.json`:
717
+
718
+ ```json
719
+ {
720
+ "definitions": [],
721
+ "models": [
722
+ {
723
+ "id": "section",
724
+ "fields": [
725
+ { "component": "text", "name": "name" },
726
+ { "component": "text", "name": "style", "multi": true },
727
+ { "component": "boolean", "name": "style_sticky" }
728
+ ]
729
+ }
730
+ ],
731
+ "filters": []
732
+ }
733
+ ```
734
+
735
+ `section-style-grouping-fallback.md`:
736
+
737
+ ```markdown
738
+ # Section Title
739
+
740
+ +-------------------------------------------------------+
741
+ | Section Metadata |
742
+ +================+======================================+
743
+ | style | highlight, featured, sticky |
744
+ +----------------+--------------------------------------+
745
+ ```
746
+
747
+ `section-style-grouping-fallback.xml`:
748
+
749
+ ```xml
750
+ <?xml version="1.0" encoding="UTF-8"?>
751
+ <jcr:root xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:nt="http://www.jcp.org/jcr/nt/1.0" xmlns:cq="http://www.day.com/jcr/cq/1.0" xmlns:sling="http://sling.apache.org/jcr/sling/1.0" jcr:primaryType="cq:Page">
752
+ <jcr:content cq:template="/libs/core/franklin/templates/page" sling:resourceType="core/franklin/components/page/v1/page" jcr:primaryType="cq:PageContent">
753
+ <root jcr:primaryType="nt:unstructured" sling:resourceType="core/franklin/components/root/v1/root">
754
+ <section sling:resourceType="core/franklin/components/section/v1/section" jcr:primaryType="nt:unstructured" style="[highlight, featured]" style_sticky="true" model="section" modelFields="[name,style,style_sticky]">
755
+ <title sling:resourceType="core/franklin/components/title/v1/title" jcr:primaryType="nt:unstructured" title="Section Title" titleType="h1"/>
756
+ </section>
757
+ </root>
758
+ </jcr:content>
759
+ </jcr:root>
760
+ ```
761
+
762
+ - [ ] **Step 6: Create fixture 4 — `section-style-grouping-booleans` (independent booleans, mixed on/off)**
763
+
764
+ `_section-style-grouping-booleans.json`:
765
+
766
+ ```json
767
+ {
768
+ "definitions": [],
769
+ "models": [
770
+ {
771
+ "id": "section",
772
+ "fields": [
773
+ { "component": "text", "name": "name" },
774
+ { "component": "boolean", "name": "style_dark" },
775
+ { "component": "boolean", "name": "style_fullwidth" },
776
+ { "component": "boolean", "name": "style_narrow" }
777
+ ]
778
+ }
779
+ ],
780
+ "filters": []
781
+ }
782
+ ```
783
+
784
+ `section-style-grouping-booleans.md`:
785
+
786
+ ```markdown
787
+ # Section Title
788
+
789
+ +-------------------------------------------------------+
790
+ | Section Metadata |
791
+ +================+======================================+
792
+ | style | dark, narrow |
793
+ +----------------+--------------------------------------+
794
+ ```
795
+
796
+ `section-style-grouping-booleans.xml`:
797
+
798
+ ```xml
799
+ <?xml version="1.0" encoding="UTF-8"?>
800
+ <jcr:root xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:nt="http://www.jcp.org/jcr/nt/1.0" xmlns:cq="http://www.day.com/jcr/cq/1.0" xmlns:sling="http://sling.apache.org/jcr/sling/1.0" jcr:primaryType="cq:Page">
801
+ <jcr:content cq:template="/libs/core/franklin/templates/page" sling:resourceType="core/franklin/components/page/v1/page" jcr:primaryType="cq:PageContent">
802
+ <root jcr:primaryType="nt:unstructured" sling:resourceType="core/franklin/components/root/v1/root">
803
+ <section sling:resourceType="core/franklin/components/section/v1/section" jcr:primaryType="nt:unstructured" style_dark="true" style_narrow="true" model="section" modelFields="[name,style_dark,style_fullwidth,style_narrow]">
804
+ <title sling:resourceType="core/franklin/components/title/v1/title" jcr:primaryType="nt:unstructured" title="Section Title" titleType="h1"/>
805
+ </section>
806
+ </root>
807
+ </jcr:content>
808
+ </jcr:root>
809
+ ```
810
+
811
+ - [ ] **Step 7: Run the section style suite**
812
+
813
+ Run: `npx mocha test/block.test.js --grep "section style options"`
814
+ Expected: PASS (4 passing).
815
+
816
+ > If a test fails on attribute formatting only (e.g. a space difference or attribute order), the implementation in `GroupOptions.js`/`section-helper.js` is the source of truth for serialization — re-read the actual output from the assertion diff and reconcile the `.xml` to it, confirming the *intent* (which token routed to which field) is correct. Do not loosen the assertion.
817
+
818
+ - [ ] **Step 8: Commit**
819
+
820
+ ```bash
821
+ git add test/fixtures/blocks/core/section-style-options test/block.test.js
822
+ git commit -m "test(section-metadata): cover style element-grouping scenarios
823
+
824
+ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
825
+ ```
826
+
827
+ ---
828
+
829
+ ### Task 6: Documentation
830
+
831
+ **Files:**
832
+ - Create: `docs/section-styles.md`
833
+ - Modify: `README.md`
834
+
835
+ - [ ] **Step 1: Write the docs page**
836
+
837
+ Create `docs/section-styles.md`:
838
+
839
+ ```markdown
840
+ # Section Styles (Element Grouping for `style`)
841
+
842
+ A section's appearance is controlled by the **Style** property of its Section
843
+ Metadata, which AEM turns into CSS classes on the containing section element.
844
+ `style` is the section counterpart of a block's [`classes`](./block-options.md)
845
+ property.
846
+
847
+ When a section model exposes several style options — especially mutually
848
+ exclusive ones — it can use the
849
+ [element grouping](https://www.aem.live/developer/component-model-definitions#element-grouping-for-block-options)
850
+ naming convention: a **style group** is the base `style` field plus any
851
+ `style_<name>` field.
852
+
853
+ ```jsonc
854
+ { "component": "select", "name": "style", "options": [ "variant-a", "variant-b" ] }
855
+ { "component": "multiselect", "name": "style_background", "options": [ "light", "dark" ] }
856
+ { "component": "boolean", "name": "style_fullwidth" }
857
+ ```
858
+
859
+ AEM collapses the whole style group into the single, well-known `style` cell of
860
+ the Section Metadata table (a flat, comma-separated list). Going from markdown to
861
+ JCR, md2jcr routes each token back to its field — exactly as it does for block
862
+ `classes`:
863
+
864
+ - **boolean field** — matched by its name suffix (after `style_`); token
865
+ `fullwidth` sets `style_fullwidth="true"`, absent leaves it unset;
866
+ - **select / multiselect field** — matched by its declared option values; a
867
+ multiselect claims all of its matching tokens (`style_background="[light, dark]"`);
868
+ - **base `style` field** — receives any leftover tokens (also the common single,
869
+ free-form `style` field case).
870
+
871
+ ### Example
872
+
873
+ ```markdown
874
+ +-------------------------------------------------------+
875
+ | Section Metadata |
876
+ +================+======================================+
877
+ | style | variant-a, light, fullwidth |
878
+ +----------------+--------------------------------------+
879
+ ```
880
+
881
+ becomes:
882
+
883
+ ```text
884
+ style="variant-a" style_background="light" style_fullwidth="true"
885
+ ```
886
+
887
+ ### Notes
888
+
889
+ - The routing rules and the underlying implementation are shared with block
890
+ options — see [Block Options](./block-options.md).
891
+ - Distribution only kicks in when the section model declares at least one
892
+ `style_<name>` field. A model with only a base `style` field keeps the simple
893
+ one-to-one mapping.
894
+ - Unlike a block's `classes` group (which is excluded from `modelFields`), the
895
+ section `style` group remains in `modelFields` so the Universal Editor can
896
+ render those fields.
897
+
898
+ ### Worked fixtures
899
+
900
+ See [`test/fixtures/blocks/core/section-style-options`](../test/fixtures/blocks/core/section-style-options):
901
+
902
+ - `section-style-grouping` — single selects + a boolean that is on
903
+ - `section-style-grouping-multi` — a multiselect claiming several values, boolean off
904
+ - `section-style-grouping-fallback` — free-form base `style` catch-all + a boolean
905
+ - `section-style-grouping-booleans` — several independent boolean options, mixed on/off
906
+ ```
907
+
908
+ - [ ] **Step 2: Link it from the README**
909
+
910
+ Find the line in `README.md` that links to `docs/block-options.md` (added by PR #215) and add a sibling link directly after it. Run to locate it:
911
+
912
+ ```bash
913
+ grep -n "block-options.md" README.md
914
+ ```
915
+
916
+ Then add, immediately after that matched line, a parallel bullet/link in the same style, e.g.:
917
+
918
+ ```markdown
919
+ - [Section Styles](docs/section-styles.md) — element grouping for section `style`
920
+ ```
921
+
922
+ (Match the exact list/link formatting used on the adjacent `block-options.md` line.)
923
+
924
+ - [ ] **Step 3: Commit**
925
+
926
+ ```bash
927
+ git add docs/section-styles.md README.md
928
+ git commit -m "docs(section-styles): document element grouping for section style
929
+
930
+ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
931
+ ```
932
+
933
+ ---
934
+
935
+ ### Task 7: Full verification
936
+
937
+ **Files:** none (verification only)
938
+
939
+ - [ ] **Step 1: Run the whole suite**
940
+
941
+ Run: `npm test`
942
+ Expected: PASS — previous count (144) + 4 new section-style fixtures + new `isGroupField` and `distributeGroupOptions` unit tests; zero failures.
943
+
944
+ - [ ] **Step 2: Lint the repo**
945
+
946
+ Run: `npm run lint`
947
+ Expected: no errors.
948
+
949
+ - [ ] **Step 3: Confirm no unintended changes**
950
+
951
+ Run: `git status` and `git diff --stat main`
952
+ Expected: only the files named in Tasks 1-6 appear.
953
+
954
+ ---
955
+
956
+ ## Self-Review
957
+
958
+ **1. Spec coverage**
959
+ - Generalize PR #215 helpers by group name → Tasks 1 (`isGroupField`), 2 (`distributeGroupOptions`), 3 (block path rewired).
960
+ - Section `style`/`style_*` distribution from the single Style cell → Task 4.
961
+ - Boolean (suffix), select/multiselect (options), base fallback routing → covered by unit tests (Task 2) and fixtures (Task 5).
962
+ - `style` group stays in section `modelFields` (asymmetry vs. blocks) → preserved by leaving `getModelFieldNames` unchanged; asserted by every fixture's `modelFields=[...]`; documented in Task 6.
963
+ - No regression to existing block options / section metadata → Task 3 Step 5 and Task 4 Step 3 guards; Task 7 full run.
964
+
965
+ **2. Placeholder scan** — No TBD/TODO/"handle edge cases"; every code step shows complete code; every fixture shows full `.md`/`.json`/`.xml`.
966
+
967
+ **3. Type/name consistency** — `isGroupField(name, group)` (Task 1) is consumed by `GroupOptions.js` (Task 2); `distributeGroupOptions(model, tokens, group='classes')` is the single name used in `grid-table.js` (Task 3) and `section-helper.js` (Task 4). The local `distributeBlockOptions`/`collectOptionValues` are fully removed from `grid-table.js` in Task 3 (no dangling references). Fixture spec names match the `_<spec>.json` / `<spec>.md` / `<spec>.xml` triple and the `testBlock(spec, folder)` calls in Task 5.
968
+
969
+ > **Known fragility:** the four `.xml` files are hand-computed golden files. Their attribute ordering assumes (a) `distributeGroupOptions` emits in model-field order and (b) `section-helper` serializes section attributes in insertion order (resourceType, primaryType, distributed style attrs, `model`, `modelFields`) without sorting — both true in the current code. Task 5 Step 7 has an explicit reconciliation note if serialization differs.