@adobe/helix-md2jcr 1.2.20 → 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,17 @@
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
+
8
+ ## [1.2.21](https://github.com/adobe/helix-md2jcr/compare/v1.2.20...v1.2.21) (2026-06-08)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **container-blocks:** correctly detect omitted parent rows ([#212](https://github.com/adobe/helix-md2jcr/issues/212)) ([cf89ba4](https://github.com/adobe/helix-md2jcr/commit/cf89ba4f040b990db2bb40a9d1b374087d63668e))
14
+
1
15
  ## [1.2.20](https://github.com/adobe/helix-md2jcr/compare/v1.2.19...v1.2.20) (2026-06-03)
2
16
 
3
17
 
package/README.md CHANGED
@@ -99,6 +99,19 @@ const xml = await md2jcr(markdown, options);
99
99
  console.log(xml);
100
100
  ```
101
101
 
102
+ ## Documentation
103
+
104
+ - [Container Blocks](docs/container-blocks.md) — how container blocks map parent
105
+ properties and child items (the one-column / many-column rules, omitting parent
106
+ rows, and error cases). See also the
107
+ [container-block test fixtures](test/fixtures/blocks/core/container-block/README.md)
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.
112
+ - [Field Hinting](docs/field-hinting.md) — using `<!-- field: name -->` to skip
113
+ ahead within a field group.
114
+
102
115
  ## Baseline XML Files
103
116
  Running the ./baseline-tests.sh script will detect any md file under test/fixtures and execute the convert2jcr node script.
104
117
  The script will generate new xml files beside the md files it locates. This is helpful when making changes to the converter
package/baseline-tests.sh CHANGED
@@ -90,8 +90,17 @@ if [ -n "$SPECIFIC_FILE" ]; then
90
90
  exit 0
91
91
  fi
92
92
 
93
- # Find all .md files recursively in the directory
94
- MD_FILES=$(find "$MD_DIR" -type f -name "*.md")
93
+ # Find all .md files recursively in the directory.
94
+ # Exclusions:
95
+ # - fixtures/bin: its .xml is a throwaway artifact generated and cleaned up by
96
+ # bin.test.js, so it has no committed baseline to diff against.
97
+ # - error-handling: these fixtures intentionally fail conversion, so running
98
+ # them through md2jcr is expected to error.
99
+ # - README.md: documentation, not a fixture.
100
+ MD_FILES=$(find "$MD_DIR" -type f -name "*.md" \
101
+ -not -name "README.md" \
102
+ -not -path "*/fixtures/bin/*" \
103
+ -not -path "*/fixtures/blocks/error-handling/*")
95
104
 
96
105
  # Check if no .md files are found
97
106
  if [ -z "$MD_FILES" ]; then
@@ -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
@@ -0,0 +1,154 @@
1
+ # Container Blocks
2
+
3
+ Container blocks are grid-table blocks that can create a parent block node and
4
+ one or more child block item nodes. They are driven by three Universal Editor
5
+ configuration files:
6
+
7
+ - The parent component definition points to a parent model and a filter.
8
+ - The filter lists the child component ids allowed inside the parent block.
9
+ - Each child component definition points to the model used for that item row.
10
+
11
+ ## Table Shape
12
+
13
+ The first row is always the block header. It names the parent block and can also
14
+ include block classes:
15
+
16
+ ```markdown
17
+ +------------------------------------------------+
18
+ | Vehicles (featured) |
19
+ +================================================+
20
+ ```
21
+
22
+ Rows after the header can be parent property rows or child item rows.
23
+
24
+ Parent property rows are single-cell rows. They map to the parent model fields
25
+ in model order:
26
+
27
+ ```markdown
28
+ | Planes, Trains, and Automobiles |
29
+ ```
30
+
31
+ Child item rows are normally multi-cell rows. The first cell can name the child
32
+ component id, followed by optional classes separated with commas:
33
+
34
+ ```markdown
35
+ | planewithclass, fast, wide | Field 1 | Field 2 | Field 3 |
36
+ ```
37
+
38
+ When the child component id is present, md2jcr skips that first cell while
39
+ mapping child model fields. In the example above, `fast` and `wide` are written
40
+ to the child item's `classes` field when that field exists.
41
+
42
+ ## Omitting Parent Rows
43
+
44
+ Container blocks can omit parent property rows entirely. When the first body row
45
+ has more than one cell, md2jcr treats all body rows as child item rows:
46
+
47
+ ```markdown
48
+ +--------------------------------------------------------+
49
+ | VehiclesWithClass (classy, vroom) |
50
+ +==========================+=========+=========+=========+
51
+ | planewithclass, x, a | Field 1 | Field 2 | Field 3 |
52
+ +--------------------------+---------+---------+---------+
53
+ | trainwithclass, large | Field 1 | Field 2 | Field 3 |
54
+ +--------------------------------------------------------+
55
+ ```
56
+
57
+ The parent block still gets its default model, filter, name, and any classes
58
+ from the header. No parent model properties are extracted from body rows.
59
+
60
+ ## Default Child Component
61
+
62
+ If a child row does not start with an allowed child component id, md2jcr uses
63
+ the first component listed in the parent filter. This allows simple child rows:
64
+
65
+ ```markdown
66
+ | Field 1 | Field 2 | Field 3 |
67
+ ```
68
+
69
+ That row maps to the first allowed child component as long as the cells match
70
+ that child model's field groups.
71
+
72
+ ## One Column vs. Many Columns
73
+
74
+ The simple rule that decides everything:
75
+
76
+ - **one column = parent info** (a parent property row)
77
+ - **two or more columns = a child item**
78
+
79
+ ```markdown
80
+ +----------------------------+
81
+ | Cards | <- the block name
82
+ +==============+=============+
83
+ | My heading | | <- ONE column = parent info
84
+ +--------------+-------------+
85
+ | card title 1 | card body 1 | <- TWO columns = a child card
86
+ +--------------+-------------+
87
+ | card title 2 | card body 2 | <- TWO columns = a child card
88
+ +--------------+-------------+
89
+ ```
90
+
91
+ A one-column row is always parent info. md2jcr does not try to guess whether you
92
+ "meant" it to be a child — if you want a child item, give it more than one
93
+ column.
94
+
95
+ ### A container can have parent info and no children
96
+
97
+ This is allowed. A Cards block can carry block-level properties without any card
98
+ items yet:
99
+
100
+ ```markdown
101
+ +----------------+
102
+ | Cards |
103
+ +================+
104
+ | My heading | <- parent info, and zero cards. This is fine.
105
+ +----------------+
106
+ ```
107
+
108
+ ## Child Component Id Without Properties
109
+
110
+ There is one one-column row that is **not** allowed: a single cell that contains
111
+ only a child component id and nothing else.
112
+
113
+ ```markdown
114
+ +----------------+
115
+ | Cards |
116
+ +================+
117
+ | card | <- one column, and the word is a child component id
118
+ +----------------+
119
+ ```
120
+
121
+ A child item always needs more than one column (its component id followed by its
122
+ property cells), so a lone component id is a half-finished child row — the author
123
+ named the child but gave it no content. md2jcr stops with an error rather than
124
+ quietly storing the component name as a parent property:
125
+
126
+ ```text
127
+ Container block row in "Container": first body row has one cell ("card") which matches the child component "card" but has no property cells. Add the child item's properties as additional cells, or remove the component id if this is parent block data.
128
+ ```
129
+
130
+ To fix it, add the child's property cells:
131
+
132
+ ```markdown
133
+ +----------------+-------------+
134
+ | Cards | |
135
+ +================+=============+
136
+ | card | card body |
137
+ +----------------+-------------+
138
+ ```
139
+
140
+ Or, if the lone value really is parent info, change it so it no longer matches a
141
+ child component name.
142
+
143
+ ## Best Practices
144
+
145
+ - Use one column for parent info and two or more columns for child items.
146
+ - Start child rows with the child component id whenever more than one child
147
+ component is allowed.
148
+ - Never write a child component id on its own line without its property cells.
149
+
150
+ ## Examples
151
+
152
+ For a worked, scenario-by-scenario breakdown of these rules — including omitted
153
+ parent rows, empty parent properties, field hints, and the error case — see the
154
+ [container-block test fixtures](../test/fixtures/blocks/core/container-block/README.md).
@@ -4,17 +4,36 @@ This document provides guidance on modeling content in the Helix MD2JCR system,
4
4
 
5
5
  ## Field Hinting
6
6
 
7
- Field hinting is a powerful feature in the Helix MD2JCR system that allows you to explicitly specify which field in a component model should receive specific content from your markdown. This is particularly useful when the automatic field resolution doesn't match your intended content structure or when you need precise control over field mapping.
7
+ Field hinting lets you skip ahead to a specific field **within a field group**
8
+ when the automatic field resolution would otherwise assign content to an earlier
9
+ field in that group. It is a tool for navigating *grouped* (collapsed) fields —
10
+ the related fields a model collapses together, such as `teaserText_title`,
11
+ `teaserText_subtitle`, and `teaserText_description`.
8
12
 
9
13
  ### Purpose of Field Hinting
10
14
 
11
- Field hinting serves several important purposes:
15
+ The feature exists to skip over grouped fields you don't want to fill:
12
16
 
13
- 1. **Precise Field Mapping**: When content doesn't naturally flow into the expected fields based on the model's field order, field hints allow you to explicitly direct content to specific fields.
17
+ 1. **Skip ahead within a field group**: When a group contains several related
18
+ fields and you only want to populate a later one, a hint jumps directly to it
19
+ and leaves the skipped fields unset.
14
20
 
15
- 2. **Content Structure Control**: In complex components with multiple fields of the same type (e.g., multiple text fields), field hints ensure content goes to the correct field.
21
+ 2. **Precise mapping among similar grouped fields**: When a group has multiple
22
+ fields of the same type (e.g. several text fields), a hint ensures the content
23
+ lands on the intended one instead of the next one in sequence.
16
24
 
17
- 3. **Sequential Field Mapping**: Field hints allow you to skip ahead to a specific field in the model's field sequence, but content must still be processed in the order it appears in the markdown.
25
+ ### Scope and Direction
26
+
27
+ Two constraints follow from how this works, and they define what the feature is
28
+ *not* for:
29
+
30
+ - **Within a single field group only.** A hint splices the current group's
31
+ remaining fields. It cannot reach across separate, ungrouped top-level fields
32
+ (fields that do not share a group prefix) — those are resolved positionally,
33
+ not by hint.
34
+ - **Forward only.** You can skip *ahead* to a later field in the group. A hint
35
+ that names a field already consumed is ignored, and the content flows into the
36
+ next available field. There is no way to hint backward.
18
37
 
19
38
  ### How Field Hinting Works
20
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/helix-md2jcr",
3
- "version": "1.2.20",
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,93 @@ 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
+
126
+ // -- Block Header -------------------------------------------------------------
127
+
41
128
  /**
42
129
  * Locate the name of the Block and any classes that are associated with it.
43
130
  * Return null if the block name can not be found in the models.
@@ -80,6 +167,40 @@ function getBlockDetails(mdast, definition) {
80
167
  return null;
81
168
  }
82
169
 
170
+ /**
171
+ * Extract the properties that are belong to the block header. Properties like
172
+ * name, model id, and classes.
173
+ * @param {Array<Model>} models - the models object
174
+ * @param {Definition} definition - the definitions object
175
+ * @param {object} mdast - the mdast tree
176
+ * @return {{
177
+ * name: string,
178
+ * model: string,
179
+ * classes?: string
180
+ * }}
181
+ */
182
+ function extractBlockHeaderProperties(models, definition, mdast) {
183
+ const blockDetails = getBlockDetails(mdast, definition);
184
+ const props = {};
185
+
186
+ props.name = blockDetails.name;
187
+ if (blockDetails.modelId) {
188
+ props.model = blockDetails.modelId;
189
+ }
190
+
191
+ const model = findModelById(models, blockDetails.modelId);
192
+
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).
195
+ if (model) {
196
+ Object.assign(props, distributeBlockOptions(model, blockDetails.classes));
197
+ }
198
+
199
+ return props;
200
+ }
201
+
202
+ // -- Field Extraction Pipeline ------------------------------------------------
203
+
83
204
  /**
84
205
  * Process the field and collapse the field into the properties object.
85
206
  * @param id {string} - the id of the field
@@ -187,16 +308,6 @@ function extractPropertiesForNode(field, currentNode, properties) {
187
308
  }
188
309
  }
189
310
 
190
- function extractKeyValueProperties(row, model, fieldResolver, fieldGroup, properties) {
191
- const [, ...nodes] = findAll(row, (node) => node.type === 'gtCell', true);
192
-
193
- for (let i = 0; i < nodes.length; i += 1) {
194
- const node = nodes[i];
195
- const field = fieldResolver.resolve(node, fieldGroup);
196
- extractPropertiesForNode(field, node, properties);
197
- }
198
- }
199
-
200
311
  /**
201
312
  * Check if the node is a field name hint.
202
313
  * @param {Node} node - the node to check
@@ -288,6 +399,16 @@ function processCell(cell, fieldGroup, fieldResolver, properties) {
288
399
  }
289
400
  }
290
401
 
402
+ function extractKeyValueProperties(row, model, fieldResolver, fieldGroup, properties) {
403
+ const [, ...nodes] = findAll(row, (node) => node.type === 'gtCell', true);
404
+
405
+ for (let i = 0; i < nodes.length; i += 1) {
406
+ const node = nodes[i];
407
+ const field = fieldResolver.resolve(node, fieldGroup);
408
+ extractPropertiesForNode(field, node, properties);
409
+ }
410
+ }
411
+
291
412
  /**
292
413
  * Extract the properties for the block item.
293
414
  * @param {*} mdast - The mdast tree.
@@ -313,14 +434,10 @@ function extractProperties(
313
434
 
314
435
  // the first cells is the header row, so we skip it
315
436
  const rows = findAll(mdast, (node) => node.type === 'gtRow', false);
316
- let classesField;
317
437
  if (mode === 'blockItem') {
318
- classesField = getField(model, 'classes');
319
- // if our model defines a classes field then dig out the classes from the first cell
320
- if (classesField) {
321
- // if we are a block item we need to look at the first cell to see if it has any class
322
- // properties by inspecting the text value for any commas
323
- // 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))) {
324
441
  const firstCell = rows[0].children[0];
325
442
  const textValue = toString(firstCell);
326
443
  const classes = textValue.split(',').map((c) => c.trim());
@@ -328,11 +445,8 @@ function extractProperties(
328
445
  // discard the component name leaving only the block option names (class names)
329
446
  classes.shift();
330
447
 
331
- // if we are left with any classes to add to the block item, then add them
332
- if (classes.length > 0) {
333
- properties.classes = classesField?.multi === true
334
- ? `[${classes.join(', ')}]` : classes.join(', ');
335
- }
448
+ // distribute the options across the model's classes group fields
449
+ Object.assign(properties, distributeBlockOptions(model, classes));
336
450
  }
337
451
  } else {
338
452
  // get rid of the header row, no need for that
@@ -375,40 +489,29 @@ function extractProperties(
375
489
  }
376
490
  }
377
491
 
378
- /**
379
- * Extract the properties that are belong to the block header. Properties like
380
- * name, model id, and classes.
381
- * @param {Array<Model>} models - the models object
382
- * @param {Definition} definition - the definitions object
383
- * @param {object} mdast - the mdast tree
384
- * @return {{
385
- * name: string,
386
- * model: string,
387
- * classes?: string
388
- * }}
389
- */
390
- function extractBlockHeaderProperties(models, definition, mdast) {
391
- const blockDetails = getBlockDetails(mdast, definition);
392
- const props = {};
393
-
394
- props.name = blockDetails.name;
395
- if (blockDetails.modelId) {
396
- props.model = blockDetails.modelId;
397
- }
492
+ // -- Container Block / Child Items --------------------------------------------
398
493
 
399
- const model = findModelById(models, blockDetails.modelId);
400
-
401
- // section metadata may not have a model
402
- if (model) {
403
- const classesField = getField(model, 'classes');
404
-
405
- if (blockDetails.classes.length > 0 && classesField) {
406
- props.classes = (classesField.multi === true)
407
- ? `[${blockDetails.classes.join(', ')}]` : blockDetails.classes.join(', ');
408
- }
409
- }
494
+ function getComponentId(cell) {
495
+ return stripNewlines(toString(cell)).split(',').shift().trim();
496
+ }
410
497
 
411
- return props;
498
+ /**
499
+ * Builds the error thrown when a single-cell body row contains only a child
500
+ * component id with no property cells. A valid child item row is multi-column
501
+ * (the component id followed by its property cells), so a lone component id is a
502
+ * malformed child row and conversion cannot continue.
503
+ * @param {{name: string}} blockHeaderProperties - The parsed block header.
504
+ * @param {string} componentId - The child component id found in the cell.
505
+ * @return {Error}
506
+ */
507
+ function childItemMissingPropertiesError(blockHeaderProperties, componentId) {
508
+ const message = [
509
+ `Container block row in "${blockHeaderProperties.name}": first body row has one cell ("${componentId}")`,
510
+ `which matches the child component "${componentId}" but has no property cells.`,
511
+ 'Add the child item\'s properties as additional cells, or remove the component id'
512
+ + ' if this is parent block data.',
513
+ ].join(' ');
514
+ return new Error(message);
412
515
  }
413
516
 
414
517
  function getBlockItems(mdast, modelHelper, definitions, allowedComponents) {
@@ -455,6 +558,8 @@ function getBlockItems(mdast, modelHelper, definitions, allowedComponents) {
455
558
  return items;
456
559
  }
457
560
 
561
+ // -- Entry Point --------------------------------------------------------------
562
+
458
563
  /**
459
564
  * The gridTablePartial function is a Handlebars partial that generates a block element.
460
565
  * @param {{models: Array<Model>,
@@ -531,12 +636,38 @@ function gridTablePartial(context) {
531
636
  // child component will, which will be handled in the Component Block Processing
532
637
  // section
533
638
  try {
639
+ const allowedComponents = modelHelper.getAllowedComponents(component);
640
+
641
+ // Pre-compute fieldGroup so it can be used for both detection and processing.
534
642
  if (model) {
535
643
  fieldGroup = modelHelper.getModelFieldGroup(model.id);
644
+ }
645
+
646
+ // Per the container-block spec, parent property rows are always single-column
647
+ // and child item rows are always multi-column. If the first body row has more
648
+ // than one cell, the author omitted parent rows entirely.
649
+ let hasParentRows = true;
650
+ if (fieldGroup && allowedComponents.length > 0) {
651
+ const allRows = findAll(mdast, (node) => node.type === 'gtRow', false);
652
+ const firstBodyRow = allRows[1]; // allRows[0] is the header gtRow
653
+ if (firstBodyRow) {
654
+ const firstCells = findAll(firstBodyRow, (n) => n.type === 'gtCell', false);
655
+ hasParentRows = firstCells.length === 1 && fieldGroup.fields.length > 0;
656
+ if (hasParentRows) {
657
+ const componentId = getComponentId(firstCells[0]);
658
+ if (allowedComponents.includes(componentId)) {
659
+ // a lone component id with no property cells is a malformed child row
660
+ throw childItemMissingPropertiesError(blockHeaderProperties, componentId);
661
+ }
662
+ }
663
+ }
664
+ }
665
+
666
+ if (model && hasParentRows) {
536
667
  extractProperties(mdast, model, mode, component, fieldGroup.fields, properties);
537
668
  } else {
538
- // because we have no model we expect the block to have a filter with a component that does
539
- // so that means we can remove the header row from the mdast tree
669
+ // no model, or model present but parent rows omitted - remove the header
670
+ // so getBlockItems only sees child item rows
540
671
  remove(mdast, (n) => is(n, { type: 'gtHeader' }));
541
672
  }
542
673
 
@@ -551,14 +682,13 @@ function gridTablePartial(context) {
551
682
  // 1. In this section attempt to locate the associated model for the block.
552
683
  // 2. Trim the mdast nodes to only be relevant for the child block.
553
684
  // 3. Then getBlockitems will process the mdast nodes and return the block items.
554
- const allowedComponents = modelHelper.getAllowedComponents(component);
555
685
 
556
686
  // collect all rows
557
687
  const blockRows = findAll(mdast, (node) => node.type === 'gtRow', true);
558
688
  // the fieldGroup (parent model) determines the expected number of rows in the table
559
689
  // so we can remove the rows that belong to the parent and leave only the
560
690
  // relevant rows for the child
561
- if (model) {
691
+ if (model && hasParentRows) {
562
692
  const removed = blockRows.splice(0, fieldGroup.fields.length + 1);
563
693
  // remove the elements from the mdast tree that match the items in the removed array
564
694
  removed.forEach((r) => {