@adobe/helix-md2jcr 1.2.21 → 1.3.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
@@ -1,3 +1,10 @@
1
+ # [1.3.0](https://github.com/adobe/helix-md2jcr/compare/v1.2.21...v1.3.0) (2026-06-08)
2
+
3
+
4
+ ### Features
5
+
6
+ * **block-options:** support element grouping for block options ([#215](https://github.com/adobe/helix-md2jcr/issues/215)) ([7388c2d](https://github.com/adobe/helix-md2jcr/commit/7388c2dcf95252393c47fc8097235f4612916342)), closes [#214](https://github.com/adobe/helix-md2jcr/issues/214)
7
+
1
8
  ## [1.2.21](https://github.com/adobe/helix-md2jcr/compare/v1.2.20...v1.2.21) (2026-06-08)
2
9
 
3
10
 
package/README.md CHANGED
@@ -106,6 +106,9 @@ console.log(xml);
106
106
  rows, and error cases). See also the
107
107
  [container-block test fixtures](test/fixtures/blocks/core/container-block/README.md)
108
108
  for a worked scenario-by-scenario breakdown.
109
+ - [Block Options (Classes)](docs/block-options.md) — block option classes and
110
+ element grouping (`classes`, `classes_*`): how header options are routed to
111
+ fields by their options or boolean name-suffix.
109
112
  - [Field Hinting](docs/field-hinting.md) — using `<!-- field: name -->` to skip
110
113
  ahead within a field group.
111
114
 
@@ -0,0 +1,115 @@
1
+ # Block Options (Classes)
2
+
3
+ Block options let an author pick variants of a block without creating a whole new
4
+ block. They are expressed as CSS classes on the block and appear in the block
5
+ header parentheses in markdown:
6
+
7
+ ```markdown
8
+ +--------------------------------------+
9
+ | Teaser (variant-a, light, fullwidth) |
10
+ +======================================+
11
+ | My title |
12
+ +--------------------------------------+
13
+ ```
14
+
15
+ A model exposes block options through its `classes` fields. In the simplest case
16
+ there is a single `classes` field that holds a free-form list of class names.
17
+
18
+ ## Element Grouping for Block Options
19
+
20
+ When there are several options — especially mutually exclusive ones — it is
21
+ easier for authors to use multiple fields. Following the
22
+ [element grouping](https://www.aem.live/developer/component-model-definitions#element-grouping-for-block-options)
23
+ naming convention, the **classes group** is the base `classes` field plus any
24
+ `classes_<name>` field (the group name `classes` separated from the option name
25
+ by an underscore):
26
+
27
+ ```jsonc
28
+ { "component": "select", "name": "classes", "options": [ "variant-a", "variant-b" ] }
29
+ { "component": "multiselect", "name": "classes_background", "options": [ "light", "dark" ] }
30
+ { "component": "boolean", "name": "classes_fullwidth" }
31
+ ```
32
+
33
+ When AEM renders the block, every field in the group collapses into one flat
34
+ class list — `class="teaser variant-a light fullwidth"` — and that is what lands
35
+ in the block header parentheses.
36
+
37
+ ## How md2jcr maps header options back to fields
38
+
39
+ Going from markdown to JCR, md2jcr must route each option in the header back to
40
+ the field it came from. Because the rendered class list is **flat** (it does not
41
+ record which field produced each class), routing is driven by the field
42
+ definition — **not by the order of the options**:
43
+
44
+ - **Boolean field** — matched by its **name suffix** (the part after `classes_`).
45
+ The token `fullwidth` sets `classes_fullwidth="true"`. If the token is absent,
46
+ the field is left unset. This suits on/off toggles.
47
+ - **Select / multiselect field** — matched by its **declared option values**. The
48
+ token `light` is routed to `classes_background` because `light` is one of that
49
+ field's options. A multiselect claims **all** of its matching tokens.
50
+ - **Base `classes` field** — receives any leftover tokens that no more specific
51
+ field claimed. This also covers the common case of a single, free-form
52
+ `classes` field.
53
+
54
+ Multi fields are written as `[a, b]`; single fields as a comma-separated value.
55
+
56
+ ### Example
57
+
58
+ Given the model above and the header `Teaser (variant-a, light, fullwidth)`, the
59
+ block node gets these properties:
60
+
61
+ ```text
62
+ classes="variant-a" classes_background="light" classes_fullwidth="true"
63
+ ```
64
+
65
+ A multiselect background claims every matching token:
66
+
67
+ ```text
68
+ Teaser (variant-a, light, dark)
69
+ -> classes="variant-a" classes_background="[light, dark]"
70
+ ```
71
+
72
+ And an unmatched token falls back to the base `classes` field:
73
+
74
+ ```text
75
+ Promo (highlight, featured, sticky) // classes is free-form, classes_sticky is boolean
76
+ -> classes="[highlight, featured]" classes_sticky="true"
77
+ ```
78
+
79
+ The classes group fields are block options, not content, so they are excluded
80
+ from `modelFields` and never consume content rows.
81
+
82
+ ## Requirements
83
+
84
+ - Name grouped option fields `classes_<name>`.
85
+ - A `classes_<name>` **select/multiselect must declare `options`** — that is how
86
+ its tokens are recognized.
87
+ - A `classes_<name>` **boolean** needs no options; its name suffix is the class.
88
+ - A **free-form text** `classes_<name>` field (no options) cannot be
89
+ reverse-mapped to its field — its token has no matching signal — so it falls
90
+ back to the base `classes` field. As the spec notes, block options are usually
91
+ select or multiselect, which avoids this.
92
+
93
+ ## Why options, not position?
94
+
95
+ Mapping the first option to the first field, the second to the second, and so on
96
+ seems simpler, but it breaks whenever the number of tokens does not equal the
97
+ number of fields — which is common:
98
+
99
+ - a boolean that is **off** contributes no token,
100
+ - an empty select contributes no token,
101
+ - a multiselect contributes **several** tokens.
102
+
103
+ Any of these slides every following option into the wrong field. Matching by a
104
+ field's declared options (and a boolean's name suffix) is order-independent and
105
+ survives missing, optional, and multi-value fields.
106
+
107
+ ## Examples in the test suite
108
+
109
+ Worked fixtures live under
110
+ [`test/fixtures/blocks/core/block-options`](../test/fixtures/blocks/core/block-options):
111
+
112
+ - `block-options-grouping` — single selects + a boolean that is on
113
+ - `block-options-grouping-multi` — a multiselect claiming several values, boolean off
114
+ - `block-options-grouping-fallback` — free-form base `classes` catch-all + a boolean
115
+ - `block-options-grouping-booleans` — several independent boolean options, mixed on/off
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/helix-md2jcr",
3
- "version": "1.2.21",
3
+ "version": "1.3.0",
4
4
  "description": "Helix Service that converts markdown to JCR content structure",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -74,12 +74,12 @@
74
74
  "husky": "^9.1.7",
75
75
  "junit-report-builder": "5.1.2",
76
76
  "lint-staged": "16.4.0",
77
- "mocha": "11.7.5",
77
+ "mocha": "11.7.6",
78
78
  "mocha-multi-reporters": "1.5.1",
79
79
  "nock": "13.5.6",
80
80
  "semantic-release": "25.0.3",
81
81
  "unist-util-inspect": "8.1.0",
82
- "yauzl": "3.3.0"
82
+ "yauzl": "3.3.1"
83
83
  },
84
84
  "lint-staged": {
85
85
  "*.js": "eslint"
@@ -9,6 +9,8 @@
9
9
  * OF ANY KIND, either express or implied. See the License for the specific language
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
+ import { isClassesField } from './Models.js';
13
+
12
14
  class FieldGroup {
13
15
  /**
14
16
  * Constructor.
@@ -35,7 +37,7 @@ class FieldGroup {
35
37
 
36
38
  this.model.fields
37
39
  .filter((field) => field.component !== 'tab')
38
- .filter((field) => field.name !== 'classes')
40
+ .filter((field) => !isClassesField(field.name))
39
41
  .forEach((field) => {
40
42
  if (field.name.includes('_')) {
41
43
  const groupName = field.name.split('_')[0];
@@ -42,7 +42,19 @@ function getField(model, fieldName) {
42
42
  }
43
43
 
44
44
  /**
45
- * Return a list of field names in the model, excluding 'classes'.
45
+ * Returns true if the field name belongs to the `classes` block-options group —
46
+ * either the base `classes` field or a grouped `classes_*` option field
47
+ * (element grouping for block options).
48
+ * @param {string} name The field name.
49
+ * @return {boolean}
50
+ */
51
+ function isClassesField(name) {
52
+ return name === 'classes' || name.startsWith('classes_');
53
+ }
54
+
55
+ /**
56
+ * Return a list of field names in the model, excluding the `classes` group
57
+ * (those fields are block options, not content).
46
58
  * @param {Model} model The model.
47
59
  * @return {Array<string>} An array of field names.
48
60
  */
@@ -51,7 +63,7 @@ function getModelFieldNames(model) {
51
63
  ? model.fields
52
64
  .filter((f) => f.component !== 'tab')
53
65
  .map((f) => f.name)
54
- .filter((f) => f !== 'classes')
66
+ .filter((f) => !isClassesField(f))
55
67
  : [];
56
68
  }
57
69
 
@@ -59,4 +71,5 @@ export {
59
71
  getField,
60
72
  findModelById,
61
73
  getModelFieldNames,
74
+ isClassesField,
62
75
  };
@@ -19,7 +19,7 @@ import { toHtml } from 'hast-util-to-html';
19
19
  import { getComponentById, getComponentByTitle, getModelId } from '../../domain/Definitions.js';
20
20
  import {
21
21
  findModelById,
22
- getField, getModelFieldNames,
22
+ getModelFieldNames, isClassesField,
23
23
  } from '../../domain/Models.js';
24
24
  import { findAll } from '../../utils/mdast.js';
25
25
  import link from './supports/link.js';
@@ -38,6 +38,91 @@ import ModelHelper from '../../domain/ModelHelper.js';
38
38
  * @typedef {import('../../index.d.ts').Filter} Filters
39
39
  */
40
40
 
41
+ // -- Block Options (classes) --------------------------------------------------
42
+
43
+ /**
44
+ * Recursively collect every option `value` from a field's options tree
45
+ * (options may be nested under `children`).
46
+ * @param {Array<object>} options - The field's options array.
47
+ * @return {Array<string>} The flat list of option values.
48
+ */
49
+ function collectOptionValues(options) {
50
+ return (options || []).flatMap((option) => {
51
+ if (option.children) {
52
+ return collectOptionValues(option.children);
53
+ }
54
+ return option.value !== undefined ? [option.value] : [];
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Distribute block-option tokens (the classes from a block header, or the
60
+ * leading cell of a child item) across the model's `classes` group fields, per
61
+ * the AEM "element grouping for block options" rules:
62
+ *
63
+ * - a boolean field receives "true" when its name suffix (after `classes_`)
64
+ * appears as a token (e.g. token "fullwidth" -> classes_fullwidth="true");
65
+ * - a text/select field with options claims the tokens that match its option
66
+ * values (e.g. token "light" -> classes_background="light");
67
+ * - any remaining tokens fall back to the base `classes` field, which also
68
+ * covers the common single-field case (a free-form `classes` field).
69
+ *
70
+ * Multi fields are written as `[a, b]`; single fields as a comma-separated list.
71
+ * @param {Model} model - The block or item model.
72
+ * @param {Array<string>} tokens - The option tokens.
73
+ * @return {Object<string, string>} A map of class field name to property value.
74
+ */
75
+ function distributeBlockOptions(model, tokens) {
76
+ const groupFields = (model?.fields || []).filter((f) => isClassesField(f.name));
77
+ if (groupFields.length === 0 || tokens.length === 0) {
78
+ return {};
79
+ }
80
+
81
+ const remaining = [...tokens];
82
+ const assigned = new Map();
83
+
84
+ // boolean fields: the suffix after `classes_` becomes the option when present
85
+ groupFields
86
+ .filter((field) => field.component === 'boolean')
87
+ .forEach((field) => {
88
+ const suffix = field.name.replace(/^classes_/, '');
89
+ const idx = remaining.indexOf(suffix);
90
+ if (idx !== -1) {
91
+ assigned.set(field.name, true);
92
+ remaining.splice(idx, 1);
93
+ }
94
+ });
95
+
96
+ // text/select fields with options: claim the tokens that match their values
97
+ groupFields
98
+ .filter((field) => field.component !== 'boolean' && field.options)
99
+ .forEach((field) => {
100
+ const values = collectOptionValues(field.options);
101
+ const matched = remaining.filter((t) => values.includes(t));
102
+ if (matched.length > 0) {
103
+ assigned.set(field.name, matched);
104
+ matched.forEach((m) => remaining.splice(remaining.indexOf(m), 1));
105
+ }
106
+ });
107
+
108
+ // leftover tokens fall back to the base `classes` field (and the single-field case)
109
+ const base = groupFields.find((f) => f.name === 'classes' && f.component !== 'boolean');
110
+ if (remaining.length > 0 && base) {
111
+ assigned.set('classes', [...(assigned.get('classes') || []), ...remaining]);
112
+ }
113
+
114
+ const result = {};
115
+ assigned.forEach((value, name) => {
116
+ if (value === true) {
117
+ result[name] = 'true';
118
+ } else {
119
+ const field = groupFields.find((f) => f.name === name);
120
+ result[name] = field.multi === true ? `[${value.join(', ')}]` : value.join(', ');
121
+ }
122
+ });
123
+ return result;
124
+ }
125
+
41
126
  // -- Block Header -------------------------------------------------------------
42
127
 
43
128
  /**
@@ -105,14 +190,10 @@ function extractBlockHeaderProperties(models, definition, mdast) {
105
190
 
106
191
  const model = findModelById(models, blockDetails.modelId);
107
192
 
108
- // section metadata may not have a model
193
+ // section metadata may not have a model. Distribute the header's block options
194
+ // across the model's classes group fields (element grouping for block options).
109
195
  if (model) {
110
- const classesField = getField(model, 'classes');
111
-
112
- if (blockDetails.classes.length > 0 && classesField) {
113
- props.classes = (classesField.multi === true)
114
- ? `[${blockDetails.classes.join(', ')}]` : blockDetails.classes.join(', ');
115
- }
196
+ Object.assign(props, distributeBlockOptions(model, blockDetails.classes));
116
197
  }
117
198
 
118
199
  return props;
@@ -353,14 +434,10 @@ function extractProperties(
353
434
 
354
435
  // the first cells is the header row, so we skip it
355
436
  const rows = findAll(mdast, (node) => node.type === 'gtRow', false);
356
- let classesField;
357
437
  if (mode === 'blockItem') {
358
- classesField = getField(model, 'classes');
359
- // if our model defines a classes field then dig out the classes from the first cell
360
- if (classesField) {
361
- // if we are a block item we need to look at the first cell to see if it has any class
362
- // properties by inspecting the text value for any commas
363
- // if there is a comma that then becomes the classes property for the block item
438
+ // if our model defines any classes group field then dig the block options out
439
+ // of the first cell (the component id followed by optional class names)
440
+ if ((model.fields || []).some((f) => isClassesField(f.name))) {
364
441
  const firstCell = rows[0].children[0];
365
442
  const textValue = toString(firstCell);
366
443
  const classes = textValue.split(',').map((c) => c.trim());
@@ -368,11 +445,8 @@ function extractProperties(
368
445
  // discard the component name leaving only the block option names (class names)
369
446
  classes.shift();
370
447
 
371
- // if we are left with any classes to add to the block item, then add them
372
- if (classes.length > 0) {
373
- properties.classes = classesField?.multi === true
374
- ? `[${classes.join(', ')}]` : classes.join(', ');
375
- }
448
+ // distribute the options across the model's classes group fields
449
+ Object.assign(properties, distributeBlockOptions(model, classes));
376
450
  }
377
451
  } else {
378
452
  // get rid of the header row, no need for that