@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 +14 -0
- package/README.md +13 -0
- package/baseline-tests.sh +11 -2
- package/docs/block-options.md +115 -0
- package/docs/container-blocks.md +154 -0
- package/docs/field-hinting.md +24 -5
- package/package.json +3 -3
- package/src/mdast2jcr/domain/FieldGroup.js +3 -1
- package/src/mdast2jcr/domain/Models.js +15 -2
- package/src/mdast2jcr/hb/partials/grid-table.js +189 -59
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
|
-
|
|
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).
|
package/docs/field-hinting.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
15
|
+
The feature exists to skip over grouped fields you don't want to fill:
|
|
12
16
|
|
|
13
|
-
1. **
|
|
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. **
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
//
|
|
320
|
-
if (
|
|
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
|
-
//
|
|
332
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
539
|
-
// so
|
|
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) => {
|