@adobe/helix-md2jcr 1.2.19 → 1.2.21

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.2.21](https://github.com/adobe/helix-md2jcr/compare/v1.2.20...v1.2.21) (2026-06-08)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **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))
7
+
8
+ ## [1.2.20](https://github.com/adobe/helix-md2jcr/compare/v1.2.19...v1.2.20) (2026-06-03)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **deps:** update adobe fixes ([#205](https://github.com/adobe/helix-md2jcr/issues/205)) ([bd667b0](https://github.com/adobe/helix-md2jcr/commit/bd667b0b5315d49e7e2f4838491c07d72a4f56dd))
14
+
1
15
  ## [1.2.19](https://github.com/adobe/helix-md2jcr/compare/v1.2.18...v1.2.19) (2026-03-31)
2
16
 
3
17
 
package/README.md CHANGED
@@ -99,6 +99,16 @@ 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
+ - [Field Hinting](docs/field-hinting.md) — using `<!-- field: name -->` to skip
110
+ ahead within a field group.
111
+
102
112
  ## Baseline XML Files
103
113
  Running the ./baseline-tests.sh script will detect any md file under test/fixtures and execute the convert2jcr node script.
104
114
  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,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.19",
3
+ "version": "1.2.21",
4
4
  "description": "Helix Service that converts markdown to JCR content structure",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -31,10 +31,10 @@
31
31
  },
32
32
  "homepage": "https://github.com/adobe/helix-md2jcr#readme",
33
33
  "dependencies": {
34
- "@adobe/fetch": "4.2.3",
34
+ "@adobe/fetch": "4.3.0",
35
35
  "@adobe/helix-markdown-support": "7.1.18",
36
- "@adobe/helix-shared-process-queue": "3.1.7",
37
- "@adobe/remark-gridtables": "3.0.19",
36
+ "@adobe/helix-shared-process-queue": "3.1.9",
37
+ "@adobe/remark-gridtables": "3.0.20",
38
38
  "entities": "^6.0.0",
39
39
  "github-slugger": "2.0.0",
40
40
  "handlebars": "^4.7.8",
@@ -57,7 +57,7 @@
57
57
  "xml-formatter": "^3.6.3"
58
58
  },
59
59
  "devDependencies": {
60
- "@adobe/eslint-config-helix": "3.0.23",
60
+ "@adobe/eslint-config-helix": "3.0.28",
61
61
  "@adobe/helix-mediahandler": "2.11.0",
62
62
  "@commitlint/cli": "^19.6.1",
63
63
  "@commitlint/config-conventional": "^19.6.0",
@@ -66,20 +66,20 @@
66
66
  "@semantic-release/git": "10.0.1",
67
67
  "c8": "10.1.3",
68
68
  "chai": "^6.0.0",
69
- "dotenv": "17.3.1",
69
+ "dotenv": "17.4.2",
70
70
  "eslint": "^9.0.0",
71
71
  "eslint-import-resolver-exports": "1.0.0-beta.5",
72
72
  "eslint-plugin-header": "3.1.1",
73
73
  "eslint-plugin-import": "2.32.0",
74
74
  "husky": "^9.1.7",
75
- "junit-report-builder": "5.1.1",
75
+ "junit-report-builder": "5.1.2",
76
76
  "lint-staged": "16.4.0",
77
77
  "mocha": "11.7.5",
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.2.1"
82
+ "yauzl": "3.3.0"
83
83
  },
84
84
  "lint-staged": {
85
85
  "*.js": "eslint"
@@ -68,7 +68,9 @@ function toRows(children) {
68
68
 
69
69
  nodes.push(node);
70
70
  } else {
71
- if (!queue) queue = [];
71
+ if (!queue) {
72
+ queue = [];
73
+ }
72
74
  queue.push(node);
73
75
  }
74
76
  });
@@ -38,6 +38,8 @@ import ModelHelper from '../../domain/ModelHelper.js';
38
38
  * @typedef {import('../../index.d.ts').Filter} Filters
39
39
  */
40
40
 
41
+ // -- Block Header -------------------------------------------------------------
42
+
41
43
  /**
42
44
  * Locate the name of the Block and any classes that are associated with it.
43
45
  * Return null if the block name can not be found in the models.
@@ -80,6 +82,44 @@ function getBlockDetails(mdast, definition) {
80
82
  return null;
81
83
  }
82
84
 
85
+ /**
86
+ * Extract the properties that are belong to the block header. Properties like
87
+ * name, model id, and classes.
88
+ * @param {Array<Model>} models - the models object
89
+ * @param {Definition} definition - the definitions object
90
+ * @param {object} mdast - the mdast tree
91
+ * @return {{
92
+ * name: string,
93
+ * model: string,
94
+ * classes?: string
95
+ * }}
96
+ */
97
+ function extractBlockHeaderProperties(models, definition, mdast) {
98
+ const blockDetails = getBlockDetails(mdast, definition);
99
+ const props = {};
100
+
101
+ props.name = blockDetails.name;
102
+ if (blockDetails.modelId) {
103
+ props.model = blockDetails.modelId;
104
+ }
105
+
106
+ const model = findModelById(models, blockDetails.modelId);
107
+
108
+ // section metadata may not have a model
109
+ 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
+ }
116
+ }
117
+
118
+ return props;
119
+ }
120
+
121
+ // -- Field Extraction Pipeline ------------------------------------------------
122
+
83
123
  /**
84
124
  * Process the field and collapse the field into the properties object.
85
125
  * @param id {string} - the id of the field
@@ -187,16 +227,6 @@ function extractPropertiesForNode(field, currentNode, properties) {
187
227
  }
188
228
  }
189
229
 
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
230
  /**
201
231
  * Check if the node is a field name hint.
202
232
  * @param {Node} node - the node to check
@@ -264,7 +294,9 @@ function processCell(cell, fieldGroup, fieldResolver, properties) {
264
294
 
265
295
  while (searching) {
266
296
  const n = cellChildren.shift();
267
- if (!n) break;
297
+ if (!n) {
298
+ break;
299
+ }
268
300
 
269
301
  // richtexts are greedy:
270
302
  // they will consume all the children until they hit an image or a named field
@@ -286,6 +318,16 @@ function processCell(cell, fieldGroup, fieldResolver, properties) {
286
318
  }
287
319
  }
288
320
 
321
+ function extractKeyValueProperties(row, model, fieldResolver, fieldGroup, properties) {
322
+ const [, ...nodes] = findAll(row, (node) => node.type === 'gtCell', true);
323
+
324
+ for (let i = 0; i < nodes.length; i += 1) {
325
+ const node = nodes[i];
326
+ const field = fieldResolver.resolve(node, fieldGroup);
327
+ extractPropertiesForNode(field, node, properties);
328
+ }
329
+ }
330
+
289
331
  /**
290
332
  * Extract the properties for the block item.
291
333
  * @param {*} mdast - The mdast tree.
@@ -373,40 +415,29 @@ function extractProperties(
373
415
  }
374
416
  }
375
417
 
376
- /**
377
- * Extract the properties that are belong to the block header. Properties like
378
- * name, model id, and classes.
379
- * @param {Array<Model>} models - the models object
380
- * @param {Definition} definition - the definitions object
381
- * @param {object} mdast - the mdast tree
382
- * @return {{
383
- * name: string,
384
- * model: string,
385
- * classes?: string
386
- * }}
387
- */
388
- function extractBlockHeaderProperties(models, definition, mdast) {
389
- const blockDetails = getBlockDetails(mdast, definition);
390
- const props = {};
418
+ // -- Container Block / Child Items --------------------------------------------
391
419
 
392
- props.name = blockDetails.name;
393
- if (blockDetails.modelId) {
394
- props.model = blockDetails.modelId;
395
- }
396
-
397
- const model = findModelById(models, blockDetails.modelId);
398
-
399
- // section metadata may not have a model
400
- if (model) {
401
- const classesField = getField(model, 'classes');
402
-
403
- if (blockDetails.classes.length > 0 && classesField) {
404
- props.classes = (classesField.multi === true)
405
- ? `[${blockDetails.classes.join(', ')}]` : blockDetails.classes.join(', ');
406
- }
407
- }
420
+ function getComponentId(cell) {
421
+ return stripNewlines(toString(cell)).split(',').shift().trim();
422
+ }
408
423
 
409
- return props;
424
+ /**
425
+ * Builds the error thrown when a single-cell body row contains only a child
426
+ * component id with no property cells. A valid child item row is multi-column
427
+ * (the component id followed by its property cells), so a lone component id is a
428
+ * malformed child row and conversion cannot continue.
429
+ * @param {{name: string}} blockHeaderProperties - The parsed block header.
430
+ * @param {string} componentId - The child component id found in the cell.
431
+ * @return {Error}
432
+ */
433
+ function childItemMissingPropertiesError(blockHeaderProperties, componentId) {
434
+ const message = [
435
+ `Container block row in "${blockHeaderProperties.name}": first body row has one cell ("${componentId}")`,
436
+ `which matches the child component "${componentId}" but has no property cells.`,
437
+ 'Add the child item\'s properties as additional cells, or remove the component id'
438
+ + ' if this is parent block data.',
439
+ ].join(' ');
440
+ return new Error(message);
410
441
  }
411
442
 
412
443
  function getBlockItems(mdast, modelHelper, definitions, allowedComponents) {
@@ -453,6 +484,8 @@ function getBlockItems(mdast, modelHelper, definitions, allowedComponents) {
453
484
  return items;
454
485
  }
455
486
 
487
+ // -- Entry Point --------------------------------------------------------------
488
+
456
489
  /**
457
490
  * The gridTablePartial function is a Handlebars partial that generates a block element.
458
491
  * @param {{models: Array<Model>,
@@ -529,12 +562,38 @@ function gridTablePartial(context) {
529
562
  // child component will, which will be handled in the Component Block Processing
530
563
  // section
531
564
  try {
565
+ const allowedComponents = modelHelper.getAllowedComponents(component);
566
+
567
+ // Pre-compute fieldGroup so it can be used for both detection and processing.
532
568
  if (model) {
533
569
  fieldGroup = modelHelper.getModelFieldGroup(model.id);
570
+ }
571
+
572
+ // Per the container-block spec, parent property rows are always single-column
573
+ // and child item rows are always multi-column. If the first body row has more
574
+ // than one cell, the author omitted parent rows entirely.
575
+ let hasParentRows = true;
576
+ if (fieldGroup && allowedComponents.length > 0) {
577
+ const allRows = findAll(mdast, (node) => node.type === 'gtRow', false);
578
+ const firstBodyRow = allRows[1]; // allRows[0] is the header gtRow
579
+ if (firstBodyRow) {
580
+ const firstCells = findAll(firstBodyRow, (n) => n.type === 'gtCell', false);
581
+ hasParentRows = firstCells.length === 1 && fieldGroup.fields.length > 0;
582
+ if (hasParentRows) {
583
+ const componentId = getComponentId(firstCells[0]);
584
+ if (allowedComponents.includes(componentId)) {
585
+ // a lone component id with no property cells is a malformed child row
586
+ throw childItemMissingPropertiesError(blockHeaderProperties, componentId);
587
+ }
588
+ }
589
+ }
590
+ }
591
+
592
+ if (model && hasParentRows) {
534
593
  extractProperties(mdast, model, mode, component, fieldGroup.fields, properties);
535
594
  } else {
536
- // because we have no model we expect the block to have a filter with a component that does
537
- // so that means we can remove the header row from the mdast tree
595
+ // no model, or model present but parent rows omitted - remove the header
596
+ // so getBlockItems only sees child item rows
538
597
  remove(mdast, (n) => is(n, { type: 'gtHeader' }));
539
598
  }
540
599
 
@@ -549,14 +608,13 @@ function gridTablePartial(context) {
549
608
  // 1. In this section attempt to locate the associated model for the block.
550
609
  // 2. Trim the mdast nodes to only be relevant for the child block.
551
610
  // 3. Then getBlockitems will process the mdast nodes and return the block items.
552
- const allowedComponents = modelHelper.getAllowedComponents(component);
553
611
 
554
612
  // collect all rows
555
613
  const blockRows = findAll(mdast, (node) => node.type === 'gtRow', true);
556
614
  // the fieldGroup (parent model) determines the expected number of rows in the table
557
615
  // so we can remove the rows that belong to the parent and leave only the
558
616
  // relevant rows for the child
559
- if (model) {
617
+ if (model && hasParentRows) {
560
618
  const removed = blockRows.splice(0, fieldGroup.fields.length + 1);
561
619
  // remove the elements from the mdast tree that match the items in the removed array
562
620
  removed.forEach((r) => {