mjml-rb 0.5.1 → 0.6.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +60 -22
  4. data/THIRD_PARTY_LICENSES.md +25 -0
  5. data/docs/ARCHITECTURE.md +363 -0
  6. data/docs/MIGRATING_TO_V5.md +112 -0
  7. data/docs/MJML_V5_SUPPORT_PLAN.md +325 -0
  8. data/docs/PARITY_AUDIT.md +124 -0
  9. data/docs/RELEASING.md +35 -0
  10. data/docs/USAGE.md +1258 -0
  11. data/docs/releases/v0.6.0.md +63 -0
  12. data/lib/mjml-rb/cli.rb +43 -31
  13. data/lib/mjml-rb/column_gutter.rb +81 -0
  14. data/lib/mjml-rb/compiler.rb +32 -122
  15. data/lib/mjml-rb/component_registry.rb +11 -1
  16. data/lib/mjml-rb/components/accordion.rb +11 -9
  17. data/lib/mjml-rb/components/attributes.rb +5 -5
  18. data/lib/mjml-rb/components/base.rb +5 -1
  19. data/lib/mjml-rb/components/body.rb +6 -3
  20. data/lib/mjml-rb/components/breakpoint.rb +1 -1
  21. data/lib/mjml-rb/components/carousel.rb +2 -2
  22. data/lib/mjml-rb/components/carousel_image.rb +2 -2
  23. data/lib/mjml-rb/components/column.rb +16 -11
  24. data/lib/mjml-rb/components/group.rb +13 -4
  25. data/lib/mjml-rb/components/head.rb +3 -3
  26. data/lib/mjml-rb/components/hero.rb +7 -2
  27. data/lib/mjml-rb/components/html_attributes.rb +3 -3
  28. data/lib/mjml-rb/components/image.rb +1 -3
  29. data/lib/mjml-rb/components/raw.rb +1 -1
  30. data/lib/mjml-rb/components/section.rb +23 -8
  31. data/lib/mjml-rb/components/social.rb +19 -28
  32. data/lib/mjml-rb/components/table.rb +2 -2
  33. data/lib/mjml-rb/config_file.rb +25 -15
  34. data/lib/mjml-rb/dependencies.rb +1 -0
  35. data/lib/mjml-rb/formatter.rb +113 -0
  36. data/lib/mjml-rb/node_compat.rb +19 -0
  37. data/lib/mjml-rb/options.rb +81 -0
  38. data/lib/mjml-rb/parser.rb +158 -131
  39. data/lib/mjml-rb/renderer.rb +84 -100
  40. data/lib/mjml-rb/result.rb +3 -2
  41. data/lib/mjml-rb/validator.rb +26 -34
  42. data/lib/mjml-rb/version.rb +2 -1
  43. data/lib/mjml-rb.rb +3 -3
  44. data/mjml-rb.gemspec +2 -2
  45. metadata +20 -2
  46. data/lib/mjml-rb/ast_node.rb +0 -37
data/docs/USAGE.md ADDED
@@ -0,0 +1,1258 @@
1
+ # mjml-rb Usage Guide
2
+
3
+ `mjml-rb` 0.6.0 is a pure-Ruby compiler targeting [MJML 5.4.1](https://mjml.io/).
4
+ Node.js/npm is only used for development reference checks, never at runtime.
5
+
6
+ - **Ruby**: >= 3.3 (CI-tested on 3.3, 3.4, and 4.0)
7
+ - **Dependencies**: css_parser (>= 1.17, < 4; CI-tested with 1.21, 2.x and 3.x), nokogiri (>= 1.13)
8
+ - **License**: MIT
9
+
10
+ ---
11
+
12
+ ## Table of Contents
13
+
14
+ - [Installation](#installation)
15
+ - [Quick Start](#quick-start)
16
+ - [Ruby API](#ruby-api)
17
+ - [MjmlRb.mjml2html](#mjmlrbmjml2html)
18
+ - [Compiler](#compiler)
19
+ - [Result Object](#result-object)
20
+ - [Compiler Options](#compiler-options)
21
+ - [CLI](#cli)
22
+ - [Flags](#flags)
23
+ - [Examples](#cli-examples)
24
+ - [Config File (.mjmlconfig)](#config-file-mjmlconfig)
25
+ - [Rails Integration](#rails-integration)
26
+ - [Components Reference](#components-reference)
27
+ - [Document Structure](#document-structure)
28
+ - [Layout Components](#layout-components)
29
+ - [Content Components](#content-components)
30
+ - [Head-only Components](#head-only-components)
31
+ - [Component Hierarchy](#component-hierarchy)
32
+ - [Includes (mj-include)](#includes-mj-include)
33
+ - [Validation](#validation)
34
+ - [Custom Components](#custom-components)
35
+
36
+ ---
37
+
38
+ ## Installation
39
+
40
+ Add to your Gemfile:
41
+
42
+ ```ruby
43
+ gem "mjml-rb"
44
+ ```
45
+
46
+ Or install directly:
47
+
48
+ ```bash
49
+ gem install mjml-rb
50
+ ```
51
+
52
+ `mjml-rb` requires Ruby 3.3 or newer.
53
+
54
+ ---
55
+
56
+ ## Quick Start
57
+
58
+ ```ruby
59
+ require "mjml-rb"
60
+
61
+ result = MjmlRb.mjml2html(<<~MJML)
62
+ <mjml>
63
+ <mj-body>
64
+ <mj-section>
65
+ <mj-column>
66
+ <mj-text>Hello World!</mj-text>
67
+ </mj-column>
68
+ </mj-section>
69
+ </mj-body>
70
+ </mjml>
71
+ MJML
72
+
73
+ puts result[:html] # => "<!doctype html>..."
74
+ puts result[:errors] # => []
75
+ ```
76
+
77
+ ---
78
+
79
+ ## Ruby API
80
+
81
+ ### MjmlRb.mjml2html
82
+
83
+ ```ruby
84
+ MjmlRb.mjml2html(mjml_string, options = {}) → Hash
85
+ ```
86
+
87
+ Compiles an MJML string and returns a hash:
88
+
89
+ ```ruby
90
+ {
91
+ html: "<!doctype html>...", # compiled HTML (empty string on fatal error)
92
+ errors: [], # array of error hashes
93
+ warnings: [] # array of warning hashes
94
+ }
95
+ ```
96
+
97
+ `MjmlRb.to_html` is an alias for `MjmlRb.mjml2html`.
98
+
99
+ ### Compiler
100
+
101
+ For repeated compilations, instantiate a compiler to reuse configuration:
102
+
103
+ ```ruby
104
+ compiler = MjmlRb::Compiler.new(
105
+ validation_level: "strict",
106
+ beautify: true,
107
+ keep_comments: false
108
+ )
109
+
110
+ result = compiler.compile(mjml_string)
111
+ result = compiler.compile(another_mjml_string) # same options
112
+ ```
113
+
114
+ You can also pass per-call overrides:
115
+
116
+ ```ruby
117
+ result = compiler.compile(mjml_string, minify: true)
118
+ ```
119
+
120
+ ### Result Object
121
+
122
+ `Compiler#compile` returns a `MjmlRb::Result`:
123
+
124
+ | Method | Return | Description |
125
+ |--------|--------|-------------|
126
+ | `html` | `String` | Compiled HTML output (empty on fatal error) |
127
+ | `errors` | `Array[Hash]` | Validation errors and parse failures |
128
+ | `warnings` | `Array[Hash]` | Non-blocking issues (soft mode include failures, etc.) |
129
+ | `success?` | `Boolean` | `true` if `errors` is empty |
130
+ | `to_h` | `Hash` | `{html:, errors:, warnings:}` |
131
+
132
+ Each error/warning hash contains:
133
+
134
+ ```ruby
135
+ {
136
+ line: Integer or nil, # source line number
137
+ message: String, # human-readable message
138
+ tag_name: String or nil, # the MJML tag involved
139
+ formatted_message: String # display-ready message
140
+ }
141
+ ```
142
+
143
+ ### Compiler Options
144
+
145
+ | Option | Type | Default | Description |
146
+ |--------|------|---------|-------------|
147
+ | `validation_level` | `String` | `"soft"` | `"soft"`, `"strict"`, or `"skip"` |
148
+ | `beautify` | `Boolean` | `false` | Pretty-print output HTML |
149
+ | `minify` | `Boolean` | `false` | Minify output HTML |
150
+ | `keep_comments` | `Boolean` | `true` | Preserve source comments; Outlook conditionals always survive |
151
+ | `ignore_includes` | `Boolean` | `true` | Silently omit `mj-include`; set false to enable allowed files |
152
+ | `printer_support` | `Boolean` | `false` | Add `@media print` CSS rules |
153
+ | `preprocessors` | `Array` | `[]` | Callables applied to MJML before parsing |
154
+ | `file_path` | `String` | `"."` | Base directory for resolving includes |
155
+ | `actual_path` | `String` | `"."` | Source filename for diagnostics |
156
+ | `lang` | `String` | `"und"` | HTML `lang` attribute |
157
+ | `dir` | `String` | `"auto"` | HTML `dir` attribute |
158
+ | `fonts` | `Hash` | *(built-in)* | `{"Font Name" => "URL"}` for Google Fonts |
159
+
160
+ Snake_case is the native Ruby form; upstream camelCase and dashed keys are
161
+ accepted aliases. Conflicting aliases raise `ArgumentError`. The compatibility
162
+ reference is exposed as `MjmlRb::MJML_COMPATIBILITY_VERSION` and in `mjml --version`.
163
+ See [the v5 migration guide](MIGRATING_TO_V5.md).
164
+
165
+ | Additional option | Default | Meaning |
166
+ |---|---|---|
167
+ | `include_path` | none | Additional canonical allowed include roots, string or array |
168
+ | `minify_options` | `{}` | `minify_css` boolean; also accepts `minifyCss` / `minifyCSS` |
169
+ | `sanitize_styles` | `false` | Validate and preserve CSS template tokens |
170
+ | `template_syntax` | `{{ }}` and `[[ ]]` pairs | Array of `{prefix:, suffix:}` objects |
171
+ | `allow_mixed_syntax` | `false` | Permit mixed block/property/value CSS template syntax |
172
+ | `mjml_config_path` | working directory | JSON config file or directory |
173
+ | `use_mjml_config_options` | `false` | Apply config options before explicit options |
174
+ | `juice_options`, `juice_preserve_tags` | none | Non-empty values are unsupported and raise |
175
+
176
+ `minify` wins over `beautify`. Formatting is conservative and preserves raw,
177
+ preformatted, script, VML and paired `htmlmin:ignore` content. Unsupported
178
+ `minify_options` keys raise instead of being ignored.
179
+
180
+ **Validation levels:**
181
+
182
+ | Level | Behavior |
183
+ |-------|----------|
184
+ | `"soft"` | Validation issues reported as warnings; compilation proceeds |
185
+ | `"strict"` | Validation issues reported as errors; compilation halts if any found |
186
+ | `"skip"` | Skip component validation; include security and diagnostics still apply |
187
+
188
+ **Preprocessors** are callables (lambdas, procs) that transform the raw MJML string before parsing:
189
+
190
+ ```ruby
191
+ upcase_tags = ->(mjml) { mjml.gsub(/mj-text/, "mj-text") }
192
+ compiler = MjmlRb::Compiler.new(preprocessors: [upcase_tags])
193
+ ```
194
+
195
+ ---
196
+
197
+ ## CLI
198
+
199
+ The gem installs a `mjml` binary:
200
+
201
+ ```bash
202
+ mjml [options] [files]
203
+ ```
204
+
205
+ ### Flags
206
+
207
+ | Flag | Alias | Argument | Description |
208
+ |------|-------|----------|-------------|
209
+ | `-r` | `--read` | `FILES` | Compile MJML file(s) (comma-separated or glob) |
210
+ | `-v` | `--validate` | `FILES` | Validate MJML file(s) with strict validation |
211
+ | `-w` | `--watch` | `FILES` | Watch file(s) and recompile on change |
212
+ | `-i` | `--stdin` | — | Read MJML from stdin |
213
+ | `-s` | `--stdout` | — | Write HTML to stdout (default output) |
214
+ | `-o` | `--output` | `PATH` | Write HTML to file or directory |
215
+ | `-c` | `--config` | `KEY=VALUE` | Set compiler option (repeatable) |
216
+ | | `--noStdoutFileComment` | — | Omit `<!-- FILE: ... -->` header on stdout |
217
+ | `-V` | `--version` | — | Print version and exit |
218
+ | `-h` | `--help` | — | Print help and exit |
219
+
220
+ Inline config is also supported: `--config.beautify=true`
221
+
222
+ ### CLI Examples
223
+
224
+ ```bash
225
+ # Compile a file to stdout
226
+ mjml -r email.mjml -s
227
+
228
+ # Compile to a file
229
+ mjml -r email.mjml -o email.html
230
+
231
+ # Compile all .mjml files in a directory
232
+ mjml -r "templates/*.mjml" -o output/
233
+
234
+ # Validate without compiling
235
+ mjml -v email.mjml
236
+
237
+ # Read from stdin, minify
238
+ mjml -i -s -c minify=true < email.mjml
239
+
240
+ # Watch mode
241
+ mjml -w email.mjml -o email.html
242
+ ```
243
+
244
+ `--validate` emits no HTML and never creates an output file. Watch mode compiles
245
+ initially and polls root files plus enabled recursive include dependencies.
246
+ `--filePath PATH` provides a base directory for stdin includes.
247
+
248
+ ### Config File (.mjmlconfig)
249
+
250
+ Place a `.mjmlconfig` file (JSON) in your project root. `.mjmlrc` remains a legacy
251
+ alias. Enable config options with `use_mjml_config_options: true` or CLI
252
+ `--config.useMjmlConfigOptions`; explicit options take precedence and
253
+ `minify_options` merges by key. `.mjmlconfig.js` and JavaScript packages are
254
+ unsupported. Ruby package files must end in `.rb`.
255
+
256
+ Example:
257
+
258
+ ```json
259
+ {
260
+ "packages": [
261
+ "./lib/my_custom_component.rb"
262
+ ],
263
+ "options": {
264
+ "beautify": true,
265
+ "validation_level": "soft"
266
+ }
267
+ }
268
+ ```
269
+
270
+ | Key | Type | Description |
271
+ |-----|------|-------------|
272
+ | `packages` | `Array[String]` | Ruby files to `require` (for custom components) |
273
+ | `options` | `Hash` | Compiler options (same as Ruby API) |
274
+
275
+ ---
276
+
277
+ ## Rails Integration
278
+
279
+ mjml-rb auto-registers a `.mjml` template handler when Rails is loaded.
280
+
281
+ ### Setup
282
+
283
+ ```ruby
284
+ # Gemfile
285
+ gem "mjml-rb"
286
+ ```
287
+
288
+ No additional configuration needed for raw XML MJML templates.
289
+
290
+ ### Mailer Template
291
+
292
+ ```xml
293
+ <!-- app/views/user_mailer/welcome.html.mjml -->
294
+ <mjml>
295
+ <mj-body>
296
+ <mj-section>
297
+ <mj-column>
298
+ <mj-text>Welcome to our service!</mj-text>
299
+ <mj-button href="https://example.com/dashboard">Go to Dashboard</mj-button>
300
+ </mj-column>
301
+ </mj-section>
302
+ </mj-body>
303
+ </mjml>
304
+ ```
305
+
306
+ Raw XML `.mjml` templates are compiled as supplied; embedded ERB is not evaluated.
307
+ For dynamic content, load an ActionView handler such as `slim-rails`, configure
308
+ `config.mjml_rb.rails_template_language = :slim`, and write the `.html.mjml`
309
+ template using that language:
310
+
311
+ ```slim
312
+ mjml
313
+ mj-body
314
+ mj-section
315
+ mj-column
316
+ mj-text = "Welcome, #{@user.name}!"
317
+ mj-button href=dashboard_url Go to Dashboard
318
+ ```
319
+
320
+ ### Configuration
321
+
322
+ ```ruby
323
+ # config/initializers/mjml.rb
324
+
325
+ # Set compiler options for Rails (default: strict validation)
326
+ MjmlRb.rails_compiler_options = {
327
+ validation_level: "strict",
328
+ keep_comments: false
329
+ }
330
+
331
+ # For Slim/Haml templates instead of raw XML:
332
+ MjmlRb.rails_template_language = :slim # or :haml
333
+ ```
334
+
335
+ Or via Railtie config:
336
+
337
+ ```ruby
338
+ # config/application.rb
339
+ config.mjml_rb.compiler_options = { validation_level: "strict" }
340
+ config.mjml_rb.rails_template_language = :slim
341
+ ```
342
+
343
+ ---
344
+
345
+ ## Components Reference
346
+
347
+ ### Document Structure
348
+
349
+ #### mj-body
350
+
351
+ Top-level content container. Required.
352
+
353
+ ```xml
354
+ <mjml>
355
+ <mj-body background-color="#f4f4f4" width="600px">
356
+ ...
357
+ </mj-body>
358
+ </mjml>
359
+ ```
360
+
361
+ | Attribute | Type | Default |
362
+ |-----------|------|---------|
363
+ | `background-color` | color | — |
364
+ | `id` | string | — |
365
+ | `width` | px | `600px` |
366
+
367
+ ---
368
+
369
+ ### Layout Components
370
+
371
+ #### mj-section
372
+
373
+ Horizontal layout container. Direct child of `mj-body`.
374
+
375
+ ```xml
376
+ <mj-section background-color="#ffffff" padding="20px 0">
377
+ <mj-column>...</mj-column>
378
+ </mj-section>
379
+ ```
380
+
381
+ | Attribute | Type | Default |
382
+ |-----------|------|---------|
383
+ | `background-color` | color | — |
384
+ | `background-url` | string | — |
385
+ | `background-repeat` | `repeat` / `no-repeat` | `repeat` |
386
+ | `background-size` | string | `auto` |
387
+ | `background-position` | string | `top center` |
388
+ | `background-position-x` | string | — |
389
+ | `background-position-y` | string | — |
390
+ | `border` | string | — |
391
+ | `border-bottom` | string | — |
392
+ | `border-left` | string | — |
393
+ | `border-right` | string | — |
394
+ | `border-top` | string | — |
395
+ | `border-radius` | string | — |
396
+ | `gutter` | px/% | — |
397
+ | `direction` | `ltr` / `rtl` | `ltr` |
398
+ | `full-width` | `full-width` / *(empty)* | — |
399
+ | `padding` | px/% (1-4 values) | `20px 0` |
400
+ | `padding-top` | px/% | — |
401
+ | `padding-right` | px/% | — |
402
+ | `padding-bottom` | px/% | — |
403
+ | `padding-left` | px/% | — |
404
+ | `text-align` | `left` / `center` / `right` | `center` |
405
+ | `text-padding` | px/% (1-4 values) | `4px 4px 4px 0` |
406
+
407
+ **Allowed children**: `mj-column`, `mj-group`, `mj-raw`
408
+
409
+ #### mj-wrapper
410
+
411
+ Like `mj-section` but wraps multiple sections. Supports section attributes except `gutter`, plus:
412
+
413
+ | Attribute | Type | Default |
414
+ |-----------|------|---------|
415
+ | `gap` | px | — |
416
+
417
+ **Allowed children**: `mj-hero`, `mj-raw`, `mj-section`
418
+
419
+ #### mj-column
420
+
421
+ Responsive column within a section. Width auto-calculated when omitted.
422
+
423
+ ```xml
424
+ <mj-column width="50%" background-color="#f0f0f0">
425
+ <mj-text>Content</mj-text>
426
+ </mj-column>
427
+ ```
428
+
429
+ | Attribute | Type | Default |
430
+ |-----------|------|---------|
431
+ | `background-color` | color | — |
432
+ | `border` | string | — |
433
+ | `border-bottom` | string | — |
434
+ | `border-left` | string | — |
435
+ | `border-right` | string | — |
436
+ | `border-top` | string | — |
437
+ | `border-radius` | string | — |
438
+ | `direction` | `ltr` / `rtl` | `ltr` |
439
+ | `inner-background-color` | color | — |
440
+ | `inner-border` | string | — |
441
+ | `inner-border-bottom` | string | — |
442
+ | `inner-border-left` | string | — |
443
+ | `inner-border-right` | string | — |
444
+ | `inner-border-top` | string | — |
445
+ | `inner-border-radius` | string | — |
446
+ | `padding` | px/% (1-4 values) | — |
447
+ | `padding-top` | px/% | — |
448
+ | `padding-right` | px/% | — |
449
+ | `padding-bottom` | px/% | — |
450
+ | `padding-left` | px/% | — |
451
+ | `vertical-align` | `top` / `middle` / `bottom` | `top` |
452
+ | `width` | px/% | *(auto)* |
453
+
454
+ **Allowed children**: `mj-accordion`, `mj-button`, `mj-carousel`, `mj-divider`, `mj-image`, `mj-raw`, `mj-social`, `mj-spacer`, `mj-table`, `mj-text`, `mj-navbar`
455
+
456
+ #### mj-group
457
+
458
+ Groups columns to prevent wrapping on mobile.
459
+
460
+ | Attribute | Type | Default |
461
+ |-----------|------|---------|
462
+ | `background-color` | color | — |
463
+ | `direction` | `ltr` / `rtl` | `ltr` |
464
+ | `vertical-align` | `top` / `middle` / `bottom` | — |
465
+ | `width` | px/% | — |
466
+
467
+ **Allowed children**: `mj-column`, `mj-raw`
468
+
469
+ #### mj-hero
470
+
471
+ Hero banner with background image overlay.
472
+
473
+ ```xml
474
+ <mj-hero mode="fluid-height" background-url="https://example.com/bg.jpg">
475
+ <mj-text>Overlay Text</mj-text>
476
+ </mj-hero>
477
+ ```
478
+
479
+ | Attribute | Type | Default |
480
+ |-----------|------|---------|
481
+ | `mode` | `fixed-height` / `fluid-height` | `fixed-height` |
482
+ | `height` | px/% | `0px` |
483
+ | `background-url` | string | — |
484
+ | `background-width` | px/% | — |
485
+ | `background-height` | px/% | — |
486
+ | `background-position` | string | `center center` |
487
+ | `background-color` | color | `#ffffff` |
488
+ | `border-radius` | string | — |
489
+ | `inner-background-color` | color | — |
490
+ | `inner-padding` | px/% (1-4 values) | — |
491
+ | `inner-padding-top` | px/% | — |
492
+ | `inner-padding-left` | px/% | — |
493
+ | `inner-padding-right` | px/% | — |
494
+ | `inner-padding-bottom` | px/% | — |
495
+ | `padding` | px/% (1-4 values) | `0px` |
496
+ | `padding-top` | px/% | — |
497
+ | `padding-right` | px/% | — |
498
+ | `padding-bottom` | px/% | — |
499
+ | `padding-left` | px/% | — |
500
+ | `vertical-align` | `top` / `middle` / `bottom` | `top` |
501
+
502
+ **Allowed children**: `mj-accordion`, `mj-button`, `mj-carousel`, `mj-divider`, `mj-image`, `mj-social`, `mj-spacer`, `mj-table`, `mj-text`, `mj-navbar`, `mj-raw`
503
+
504
+ ---
505
+
506
+ ### Content Components
507
+
508
+ #### mj-text
509
+
510
+ Block of text/HTML content.
511
+
512
+ ```xml
513
+ <mj-text font-size="16px" color="#333333" align="center">
514
+ <p>Hello <strong>World</strong>!</p>
515
+ </mj-text>
516
+ ```
517
+
518
+ | Attribute | Type | Default |
519
+ |-----------|------|---------|
520
+ | `align` | `left` / `right` / `center` / `justify` | `left` |
521
+ | `background-color` | color | — |
522
+ | `color` | color | `#000000` |
523
+ | `container-background-color` | color | — |
524
+ | `font-family` | string | `Ubuntu, Helvetica, Arial, sans-serif` |
525
+ | `font-size` | px | `13px` |
526
+ | `font-style` | string | — |
527
+ | `font-weight` | string | — |
528
+ | `height` | px/% | — |
529
+ | `letter-spacing` | px/em (negative ok) | — |
530
+ | `line-height` | px/%/unitless | `1` |
531
+ | `padding` | px/% (1-4 values) | `10px 25px` |
532
+ | `padding-top` | px/% | — |
533
+ | `padding-right` | px/% | — |
534
+ | `padding-bottom` | px/% | — |
535
+ | `padding-left` | px/% | — |
536
+ | `text-decoration` | string | — |
537
+ | `text-transform` | string | — |
538
+ | `vertical-align` | `top` / `middle` / `bottom` | — |
539
+
540
+ #### mj-image
541
+
542
+ Responsive image with optional link.
543
+
544
+ ```xml
545
+ <mj-image src="https://example.com/image.jpg"
546
+ alt="Description"
547
+ href="https://example.com"
548
+ width="300px" />
549
+ ```
550
+
551
+ | Attribute | Type | Default |
552
+ |-----------|------|---------|
553
+ | `src` | string | — |
554
+ | `alt` | string | `""` |
555
+ | `href` | string | — |
556
+ | `name` | string | — |
557
+ | `title` | string | — |
558
+ | `rel` | string | — |
559
+ | `srcset` | string | — |
560
+ | `sizes` | string | — |
561
+ | `align` | `left` / `center` / `right` | `center` |
562
+ | `border` | string | `0` |
563
+ | `border-bottom` | string | — |
564
+ | `border-left` | string | — |
565
+ | `border-right` | string | — |
566
+ | `border-top` | string | — |
567
+ | `border-radius` | string | — |
568
+ | `container-background-color` | color | — |
569
+ | `fluid-on-mobile` | boolean | — |
570
+ | `full-width` | `full-width` | — |
571
+ | `padding` | px/% (1-4 values) | `10px 25px` |
572
+ | `padding-top` | px/% | — |
573
+ | `padding-right` | px/% | — |
574
+ | `padding-bottom` | px/% | — |
575
+ | `padding-left` | px/% | — |
576
+ | `target` | string | `_blank` |
577
+ | `width` | px | — |
578
+ | `height` | px/auto | `auto` |
579
+ | `max-height` | px/% | — |
580
+ | `font-size` | px | `13px` |
581
+ | `usemap` | string | — |
582
+
583
+ #### mj-button
584
+
585
+ Call-to-action button. Inner HTML is preserved.
586
+
587
+ ```xml
588
+ <mj-button href="https://example.com"
589
+ background-color="#ff6600"
590
+ color="#ffffff"
591
+ border-radius="8px">
592
+ Sign Up Now
593
+ </mj-button>
594
+ ```
595
+
596
+ | Attribute | Type | Default |
597
+ |-----------|------|---------|
598
+ | `href` | string | — |
599
+ | `name` | string | — |
600
+ | `title` | string | — |
601
+ | `rel` | string | — |
602
+ | `align` | `left` / `center` / `right` | `center` |
603
+ | `background-color` | color | `#414141` |
604
+ | `border` | string | `none` |
605
+ | `border-bottom` | string | — |
606
+ | `border-left` | string | — |
607
+ | `border-right` | string | — |
608
+ | `border-top` | string | — |
609
+ | `border-radius` | string | `3px` |
610
+ | `color` | color | `#ffffff` |
611
+ | `container-background-color` | color | — |
612
+ | `font-family` | string | `Ubuntu, Helvetica, Arial, sans-serif` |
613
+ | `font-size` | px | `13px` |
614
+ | `font-style` | string | — |
615
+ | `font-weight` | string | `normal` |
616
+ | `height` | px/% | — |
617
+ | `inner-padding` | px/% (1-4 values) | `10px 25px` |
618
+ | `letter-spacing` | px/em (negative ok) | — |
619
+ | `line-height` | px/%/unitless | `120%` |
620
+ | `padding` | px/% (1-4 values) | `10px 25px` |
621
+ | `padding-top` | px/% | — |
622
+ | `padding-right` | px/% | — |
623
+ | `padding-bottom` | px/% | — |
624
+ | `padding-left` | px/% | — |
625
+ | `target` | string | `_blank` |
626
+ | `text-decoration` | string | `none` |
627
+ | `text-transform` | string | `none` |
628
+ | `text-align` | `left` / `right` / `center` | — |
629
+ | `vertical-align` | `top` / `middle` / `bottom` | `middle` |
630
+ | `width` | px/% | — |
631
+
632
+ When `href` is omitted, renders a `<p>` tag instead of `<a>`.
633
+
634
+ #### mj-divider
635
+
636
+ Horizontal rule/divider.
637
+
638
+ ```xml
639
+ <mj-divider border-color="#cccccc" border-width="1px" border-style="dashed" />
640
+ ```
641
+
642
+ | Attribute | Type | Default |
643
+ |-----------|------|---------|
644
+ | `align` | `left` / `center` / `right` | `center` |
645
+ | `border-color` | color | `#000000` |
646
+ | `border-style` | string | `solid` |
647
+ | `border-width` | px | `4px` |
648
+ | `container-background-color` | color | — |
649
+ | `padding` | px/% (1-4 values) | `10px 25px` |
650
+ | `padding-top` | px/% | — |
651
+ | `padding-right` | px/% | — |
652
+ | `padding-bottom` | px/% | — |
653
+ | `padding-left` | px/% | — |
654
+ | `width` | px/% | `100%` |
655
+
656
+ #### mj-spacer
657
+
658
+ Vertical spacing element.
659
+
660
+ ```xml
661
+ <mj-spacer height="40px" />
662
+ ```
663
+
664
+ | Attribute | Type | Default |
665
+ |-----------|------|---------|
666
+ | `height` | px/% | `20px` |
667
+ | `border` | string | — |
668
+ | `border-bottom` | string | — |
669
+ | `border-left` | string | — |
670
+ | `border-right` | string | — |
671
+ | `border-top` | string | — |
672
+ | `container-background-color` | color | — |
673
+ | `padding` | px/% (1-4 values) | — |
674
+ | `padding-top` | px/% | — |
675
+ | `padding-right` | px/% | — |
676
+ | `padding-bottom` | px/% | — |
677
+ | `padding-left` | px/% | — |
678
+
679
+ #### mj-table
680
+
681
+ HTML table with email-safe defaults. Inner HTML is preserved.
682
+
683
+ ```xml
684
+ <mj-table>
685
+ <tr>
686
+ <th>Name</th>
687
+ <th>Price</th>
688
+ </tr>
689
+ <tr>
690
+ <td>Widget</td>
691
+ <td>$9.99</td>
692
+ </tr>
693
+ </mj-table>
694
+ ```
695
+
696
+ | Attribute | Type | Default |
697
+ |-----------|------|---------|
698
+ | `align` | `left` / `right` / `center` | `left` |
699
+ | `border` | string | `none` |
700
+ | `cellpadding` | integer | `0` |
701
+ | `cellspacing` | integer | `0` |
702
+ | `color` | color | `#000000` |
703
+ | `container-background-color` | color | — |
704
+ | `font-family` | string | `Ubuntu, Helvetica, Arial, sans-serif` |
705
+ | `font-size` | px | `13px` |
706
+ | `font-weight` | string | — |
707
+ | `line-height` | px/%/unitless | `22px` |
708
+ | `padding` | px/% (1-4 values) | `10px 25px` |
709
+ | `padding-top` | px/% | — |
710
+ | `padding-right` | px/% | — |
711
+ | `padding-bottom` | px/% | — |
712
+ | `padding-left` | px/% | — |
713
+ | `role` | `none` / `presentation` | — |
714
+ | `table-layout` | `auto` / `fixed` / `initial` / `inherit` | `auto` |
715
+ | `vertical-align` | `top` / `middle` / `bottom` | — |
716
+ | `width` | px/%/auto | `100%` |
717
+
718
+ #### mj-raw
719
+
720
+ Insert raw HTML directly. Not processed by the MJML engine.
721
+
722
+ ```xml
723
+ <mj-raw>
724
+ <div class="custom">Any HTML here</div>
725
+ </mj-raw>
726
+ ```
727
+
728
+ | Attribute | Type | Default |
729
+ |-----------|------|---------|
730
+ | `position` | `file-start` | — |
731
+
732
+ Use `position="file-start"` to insert content before the `<!doctype>`.
733
+
734
+ #### mj-social
735
+
736
+ Social media icon links.
737
+
738
+ ```xml
739
+ <mj-social font-size="14px" icon-size="24px" mode="horizontal">
740
+ <mj-social-element name="facebook" href="https://facebook.com/page">
741
+ Facebook
742
+ </mj-social-element>
743
+ <mj-social-element name="twitter" href="https://twitter.com/handle">
744
+ Twitter
745
+ </mj-social-element>
746
+ </mj-social>
747
+ ```
748
+
749
+ **mj-social attributes:**
750
+
751
+ | Attribute | Type | Default |
752
+ |-----------|------|---------|
753
+ | `align` | `left` / `center` / `right` | `center` |
754
+ | `border` | string | `0` on icons; inherited from parent |
755
+ | `border-radius` | string | `3px` |
756
+ | `color` | color | `#333333` |
757
+ | `container-background-color` | color | — |
758
+ | `font-family` | string | `Ubuntu, Helvetica, Arial, sans-serif` |
759
+ | `font-size` | px | `13px` |
760
+ | `font-style` | string | — |
761
+ | `font-weight` | string | — |
762
+ | `icon-size` | px/% | `20px` |
763
+ | `icon-height` | px/% | — |
764
+ | `icon-padding` | px/% (1-4 values) | — |
765
+ | `inner-padding` | px/% (1-4 values) | — |
766
+ | `line-height` | px/%/unitless | `22px` |
767
+ | `mode` | `horizontal` / `vertical` | `horizontal` |
768
+ | `padding` | px/% (1-4 values) | `10px 25px` |
769
+ | `padding-top` | px/% | — |
770
+ | `padding-right` | px/% | — |
771
+ | `padding-bottom` | px/% | — |
772
+ | `padding-left` | px/% | — |
773
+ | `table-layout` | `auto` / `fixed` | — |
774
+ | `text-padding` | px/% (1-4 values) | — |
775
+ | `text-decoration` | string | `none` |
776
+ | `vertical-align` | `top` / `middle` / `bottom` | — |
777
+
778
+ **mj-social-element attributes:**
779
+
780
+ | Attribute | Type | Default |
781
+ |-----------|------|---------|
782
+ | `name` | string | — |
783
+ | `href` | string | — |
784
+ | `src` | string | — |
785
+ | `srcset` | string | — |
786
+ | `sizes` | string | — |
787
+ | `alt` | string | `""` |
788
+ | `title` | string | — |
789
+ | `target` | string | `_blank` |
790
+ | `rel` | string | — |
791
+ | `align` | `left` / `center` / `right` | `left` |
792
+ | `icon-position` | `left` / `right` | `left` |
793
+ | `background-color` | color | — |
794
+ | `border` | string | `0` on icons; inherited from parent |
795
+ | `border-radius` | string | `3px` |
796
+ | `color` | color | `#000` |
797
+ | `font-family` | string | `Ubuntu, Helvetica, Arial, sans-serif` |
798
+ | `font-size` | px | `13px` |
799
+ | `font-style` | string | — |
800
+ | `font-weight` | string | — |
801
+ | `icon-size` | px/% | — |
802
+ | `icon-height` | px/% | — |
803
+ | `icon-padding` | px/% (1-4 values) | — |
804
+ | `line-height` | px/%/unitless | `1` |
805
+ | `padding` | px/% (1-4 values) | `4px` |
806
+ | `padding-top` | px/% | — |
807
+ | `padding-right` | px/% | — |
808
+ | `padding-bottom` | px/% | — |
809
+ | `padding-left` | px/% | — |
810
+ | `text-padding` | px/% (1-4 values) | `4px 4px 4px 0` |
811
+ | `text-decoration` | string | `none` |
812
+ | `vertical-align` | `top` / `middle` / `bottom` | `middle` |
813
+
814
+ **Built-in network names**: `facebook`, `twitter`, `x`, `google`, `pinterest`, `linkedin`, `instagram`, `web`, `snapchat`, `youtube`, `tumblr`, `github`, `xing`, `vimeo`, `medium`, `soundcloud`, `dribbble`. Each also has a `-noshare` variant (e.g. `facebook-noshare`).
815
+
816
+ #### mj-navbar
817
+
818
+ Navigation bar with optional hamburger menu for mobile.
819
+
820
+ ```xml
821
+ <mj-navbar hamburger="hamburger">
822
+ <mj-navbar-link href="/home">Home</mj-navbar-link>
823
+ <mj-navbar-link href="/about">About</mj-navbar-link>
824
+ </mj-navbar>
825
+ ```
826
+
827
+ **mj-navbar attributes:**
828
+
829
+ | Attribute | Type | Default |
830
+ |-----------|------|---------|
831
+ | `align` | `left` / `center` / `right` | — |
832
+ | `base-url` | string | — |
833
+ | `hamburger` | `hamburger` | — |
834
+ | `ico-align` | `left` / `center` / `right` | — |
835
+ | `ico-open` | string | — |
836
+ | `ico-close` | string | — |
837
+ | `ico-color` | color | — |
838
+ | `ico-font-size` | px/% | — |
839
+ | `ico-font-family` | string | — |
840
+ | `ico-text-transform` | string | — |
841
+ | `ico-padding` | px/% (1-4 values) | — |
842
+ | `ico-padding-top` | px/% | — |
843
+ | `ico-padding-right` | px/% | — |
844
+ | `ico-padding-bottom` | px/% | — |
845
+ | `ico-padding-left` | px/% | — |
846
+ | `ico-text-decoration` | string | — |
847
+ | `ico-line-height` | px/%/unitless | — |
848
+ | `padding` | px/% (1-4 values) | — |
849
+ | `padding-top` | px/% | — |
850
+ | `padding-right` | px/% | — |
851
+ | `padding-bottom` | px/% | — |
852
+ | `padding-left` | px/% | — |
853
+
854
+ **mj-navbar-link attributes:**
855
+
856
+ | Attribute | Type | Default |
857
+ |-----------|------|---------|
858
+ | `href` | string | — |
859
+ | `name` | string | — |
860
+ | `target` | string | — |
861
+ | `rel` | string | — |
862
+ | `color` | color | — |
863
+ | `font-family` | string | — |
864
+ | `font-size` | px | — |
865
+ | `font-style` | string | — |
866
+ | `font-weight` | string | — |
867
+ | `letter-spacing` | px/em (negative ok) | — |
868
+ | `line-height` | px/%/unitless | — |
869
+ | `padding` | px/% (1-4 values) | — |
870
+ | `padding-top` | px/% | — |
871
+ | `padding-right` | px/% | — |
872
+ | `padding-bottom` | px/% | — |
873
+ | `padding-left` | px/% | — |
874
+ | `text-decoration` | string | — |
875
+ | `text-transform` | string | — |
876
+
877
+ #### mj-carousel
878
+
879
+ Image carousel with thumbnails.
880
+
881
+ ```xml
882
+ <mj-carousel thumbnails="visible">
883
+ <mj-carousel-image src="https://example.com/1.jpg" />
884
+ <mj-carousel-image src="https://example.com/2.jpg" />
885
+ </mj-carousel>
886
+ ```
887
+
888
+ **mj-carousel attributes:**
889
+
890
+ | Attribute | Type | Default |
891
+ |-----------|------|---------|
892
+ | `align` | `left` / `center` / `right` | `center` |
893
+ | `border-radius` | string | `6px` |
894
+ | `container-background-color` | color | — |
895
+ | `icon-width` | px/% | `44px` |
896
+ | `left-icon` | string (URL) | *(imgur default)* |
897
+ | `right-icon` | string (URL) | *(imgur default)* |
898
+ | `thumbnails` | `visible` / `hidden` / `supported` | `visible` |
899
+ | `tb-border` | string | `2px solid transparent` |
900
+ | `tb-border-radius` | string | `6px` |
901
+ | `tb-hover-border-color` | color | `#fead0d` |
902
+ | `tb-selected-border-color` | color | `#ccc` |
903
+ | `tb-width` | px/% | — |
904
+ | `padding` | px/% (1-4 values) | — |
905
+ | `padding-top` | px/% | — |
906
+ | `padding-right` | px/% | — |
907
+ | `padding-bottom` | px/% | — |
908
+ | `padding-left` | px/% | — |
909
+
910
+ **mj-carousel-image attributes:**
911
+
912
+ | Attribute | Type | Default |
913
+ |-----------|------|---------|
914
+ | `src` | string | — |
915
+ | `alt` | string | `""` |
916
+ | `href` | string | — |
917
+ | `rel` | string | — |
918
+ | `target` | string | `_blank` |
919
+ | `title` | string | — |
920
+ | `thumbnails-src` | string | — |
921
+ | `border-radius` | string | — |
922
+ | `tb-border` | string | — |
923
+ | `tb-border-radius` | string | — |
924
+
925
+ #### mj-accordion
926
+
927
+ Expandable/collapsible sections.
928
+
929
+ ```xml
930
+ <mj-accordion border="1px solid #ddd">
931
+ <mj-accordion-element>
932
+ <mj-accordion-title>Section 1</mj-accordion-title>
933
+ <mj-accordion-text>Content for section 1.</mj-accordion-text>
934
+ </mj-accordion-element>
935
+ </mj-accordion>
936
+ ```
937
+
938
+ **mj-accordion attributes:**
939
+
940
+ | Attribute | Type | Default |
941
+ |-----------|------|---------|
942
+ | `border` | string | `2px solid black` |
943
+ | `container-background-color` | color | — |
944
+ | `font-family` | string | `Ubuntu, Helvetica, Arial, sans-serif` |
945
+ | `icon-align` | `top` / `middle` / `bottom` | `middle` |
946
+ | `icon-width` | px/% | `32px` |
947
+ | `icon-height` | px/% | `32px` |
948
+ | `icon-wrapped-url` | string | *(default +)* |
949
+ | `icon-wrapped-alt` | string | `+` |
950
+ | `icon-unwrapped-url` | string | *(default -)* |
951
+ | `icon-unwrapped-alt` | string | `-` |
952
+ | `icon-position` | `left` / `right` | `right` |
953
+ | `padding` | px/% (1-4 values) | `10px 25px` |
954
+ | `padding-top` | px/% | — |
955
+ | `padding-right` | px/% | — |
956
+ | `padding-bottom` | px/% | — |
957
+ | `padding-left` | px/% | — |
958
+
959
+ ---
960
+
961
+ ### Head-only Components
962
+
963
+ These go inside `<mj-head>` and configure the document.
964
+
965
+ #### mj-breakpoint
966
+
967
+ Set the mobile breakpoint width.
968
+
969
+ ```xml
970
+ <mj-head>
971
+ <mj-breakpoint width="600px" />
972
+ </mj-head>
973
+ ```
974
+
975
+ | Attribute | Type | Default |
976
+ |-----------|------|---------|
977
+ | `width` | px | `480px` |
978
+
979
+ #### mj-title
980
+
981
+ Set the HTML `<title>` tag.
982
+
983
+ ```xml
984
+ <mj-title>My Email Subject</mj-title>
985
+ ```
986
+
987
+ #### mj-preview
988
+
989
+ Set hidden preview text (shown in inbox list).
990
+
991
+ ```xml
992
+ <mj-preview>This is the preview text shown in email clients.</mj-preview>
993
+ ```
994
+
995
+ #### mj-font
996
+
997
+ Register a Google Font for use in the email.
998
+
999
+ ```xml
1000
+ <mj-font name="Montserrat" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700" />
1001
+ ```
1002
+
1003
+ | Attribute | Type | Default |
1004
+ |-----------|------|---------|
1005
+ | `name` | string | — |
1006
+ | `href` | string | — |
1007
+
1008
+ **Built-in fonts** (no `mj-font` needed): Open Sans, Droid Sans, Lato, Roboto, Ubuntu.
1009
+
1010
+ #### mj-style
1011
+
1012
+ Inject custom CSS into the `<head>`.
1013
+
1014
+ ```xml
1015
+ <mj-style>
1016
+ .custom-class { color: red; }
1017
+ </mj-style>
1018
+
1019
+ <!-- Inline styles into element style attributes: -->
1020
+ <mj-style inline="inline">
1021
+ .btn a { color: #00ada5; }
1022
+ </mj-style>
1023
+ ```
1024
+
1025
+ | Attribute | Type | Default |
1026
+ |-----------|------|---------|
1027
+ | `inline` | `inline` | — |
1028
+
1029
+ #### mj-attributes
1030
+
1031
+ Set default attributes for components globally or by class.
1032
+
1033
+ ```xml
1034
+ <mj-head>
1035
+ <mj-attributes>
1036
+ <mj-all font-family="Helvetica" />
1037
+ <mj-text font-size="16px" color="#333" />
1038
+ <mj-class name="headline" font-size="24px" font-weight="bold" />
1039
+ </mj-attributes>
1040
+ </mj-head>
1041
+ ```
1042
+
1043
+ Use `mj-class="headline"` on any component to apply class defaults.
1044
+
1045
+ #### mj-html-attributes
1046
+
1047
+ Add HTML attributes to rendered elements via CSS selectors.
1048
+
1049
+ ```xml
1050
+ <mj-head>
1051
+ <mj-html-attributes>
1052
+ <mj-selector path=".custom-table table">
1053
+ <mj-html-attribute name="role">presentation</mj-html-attribute>
1054
+ </mj-selector>
1055
+ </mj-html-attributes>
1056
+ </mj-head>
1057
+ ```
1058
+
1059
+ ---
1060
+
1061
+ ## Component Hierarchy
1062
+
1063
+ ```
1064
+ mjml
1065
+ ├── mj-head
1066
+ │ ├── mj-attributes
1067
+ │ ├── mj-breakpoint
1068
+ │ ├── mj-font
1069
+ │ ├── mj-html-attributes
1070
+ │ ├── mj-preview
1071
+ │ ├── mj-style
1072
+ │ ├── mj-title
1073
+ │ └── mj-raw
1074
+ └── mj-body
1075
+ ├── mj-section
1076
+ │ ├── mj-column
1077
+ │ │ ├── mj-accordion
1078
+ │ │ ├── mj-button
1079
+ │ │ ├── mj-carousel
1080
+ │ │ ├── mj-divider
1081
+ │ │ ├── mj-image
1082
+ │ │ ├── mj-navbar
1083
+ │ │ ├── mj-raw
1084
+ │ │ ├── mj-social
1085
+ │ │ ├── mj-spacer
1086
+ │ │ ├── mj-table
1087
+ │ │ └── mj-text
1088
+ │ ├── mj-group
1089
+ │ │ └── mj-column
1090
+ │ └── mj-raw
1091
+ ├── mj-wrapper
1092
+ │ ├── mj-section
1093
+ │ ├── mj-hero
1094
+ │ └── mj-raw
1095
+ ├── mj-hero
1096
+ │ └── (same children as mj-column)
1097
+ └── mj-raw
1098
+ ```
1099
+
1100
+ **Global attributes** available on all components: `css-class`, `mj-class`.
1101
+
1102
+ ---
1103
+
1104
+ ## Includes (mj-include)
1105
+
1106
+ Includes are disabled by default. Enable them with `ignore_includes: false` or
1107
+ `--config.allowIncludes`. Set `file_path` to the template directory; the CLI does
1108
+ this for each file. `include_path` adds explicit allowed directories. Nested
1109
+ includes keep the original allowlist while resolving relative to their own file.
1110
+ Canonical path checks reject escapes through traversal or symlinks, as well as
1111
+ absolute, UNC, drive-letter and null-byte paths.
1112
+
1113
+ Include external MJML, HTML, or CSS files:
1114
+
1115
+ ```xml
1116
+ <!-- Include MJML content (parsed and merged) -->
1117
+ <mj-include path="./partials/header.mjml" />
1118
+
1119
+ <!-- Include raw HTML (wrapped in mj-raw) -->
1120
+ <mj-include path="./snippets/tracking.html" type="html" />
1121
+
1122
+ <!-- Include CSS (added to <head>) -->
1123
+ <mj-include path="./styles/theme.css" type="css" />
1124
+
1125
+ <!-- Include CSS and inline it into style attributes -->
1126
+ <mj-include path="./styles/inline.css" type="css" css-inline="inline" />
1127
+ ```
1128
+
1129
+ | Attribute | Type | Description |
1130
+ |-----------|------|-------------|
1131
+ | `path` | string | File path to include (**required**) |
1132
+ | `type` | `html` / `css` | Include mode (default: MJML content) |
1133
+ | `css-inline` | `inline` | Inline CSS into style attributes (only with `type="css"`) |
1134
+
1135
+ - Paths are resolved relative to the including file
1136
+ - Circular includes are detected and raise an error
1137
+ - Missing or denied files produce structured diagnostics: warnings (soft/skip) or errors (strict)
1138
+ - Ignored includes are removed before validation; denied includes emit `<!-- mj-include denied -->`
1139
+ - CSS includes in `mj-head` retain source order
1140
+ - Nested includes are fully supported
1141
+
1142
+ ---
1143
+
1144
+ ## Validation
1145
+
1146
+ The validator checks:
1147
+
1148
+ 1. **Root element** must be `<mjml>`
1149
+ 2. **Required children** — `<mj-body>` must exist
1150
+ 3. **Known tags** — only registered MJML components allowed
1151
+ 4. **Hierarchy** — children must be valid for their parent
1152
+ 5. **Supported attributes** — unknown attributes are flagged
1153
+ 6. **Attribute types** — values validated against type definitions
1154
+
1155
+ Empty `mj-image` and `mj-font` elements are valid in v5; Ruby-only required
1156
+ attribute checks have been removed. Enabled `mj-include` elements still need a
1157
+ `path` for the parser to resolve the file.
1158
+
1159
+ **Attribute type definitions:**
1160
+
1161
+ | Type | Example Values |
1162
+ |------|---------------|
1163
+ | `string` | Any string |
1164
+ | `boolean` | `"true"`, `"false"` |
1165
+ | `integer` | `"42"` |
1166
+ | `color` | `#fff`, `#ff0000`, `rgb(0,0,0)`, `hsl(0,0%,0%)`, `red` |
1167
+ | `enum(a,b,c)` | One of the listed values |
1168
+ | `unit(px)` | `"13px"` |
1169
+ | `unit(px,%)` | `"13px"`, `"50%"` |
1170
+ | `unit(px,%){1,4}` | `"10px"`, `"10px 20px"`, `"10px 20px 30px 40px"` |
1171
+ | `unitWithNegative(px,em)` | `"-1px"`, `"0.5em"` |
1172
+
1173
+ ---
1174
+
1175
+ ## Custom Components
1176
+
1177
+ ### Define a Component
1178
+
1179
+ ```ruby
1180
+ class MjBadge < MjmlRb::Components::Base
1181
+ TAGS = ["mj-badge"].freeze
1182
+
1183
+ ALLOWED_ATTRIBUTES = {
1184
+ "text" => "string",
1185
+ "background-color" => "color",
1186
+ "color" => "color",
1187
+ "border-radius" => "unit(px)",
1188
+ "padding" => "unit(px,%){1,4}"
1189
+ }.freeze
1190
+
1191
+ DEFAULT_ATTRIBUTES = {
1192
+ "background-color" => "#e74c3c",
1193
+ "color" => "#ffffff",
1194
+ "border-radius" => "4px",
1195
+ "padding" => "4px 8px"
1196
+ }.freeze
1197
+
1198
+ def render(tag_name:, node:, context:, attrs:, parent:)
1199
+ a = DEFAULT_ATTRIBUTES.merge(attrs)
1200
+ style = style_join(
1201
+ "background-color" => a["background-color"],
1202
+ "color" => a["color"],
1203
+ "border-radius" => a["border-radius"],
1204
+ "padding" => a["padding"],
1205
+ "display" => "inline-block",
1206
+ "font-size" => "12px"
1207
+ )
1208
+ text = a["text"] || raw_inner(node)
1209
+ %(<tr><td><span style="#{style}">#{escape_html(text)}</span></td></tr>)
1210
+ end
1211
+ end
1212
+ ```
1213
+
1214
+ ### Register
1215
+
1216
+ ```ruby
1217
+ MjmlRb.register_component(
1218
+ MjBadge,
1219
+ dependencies: { "mj-column" => ["mj-badge"] },
1220
+ ending_tags: [] # set to ["mj-badge"] if inner HTML should be preserved raw
1221
+ )
1222
+ ```
1223
+
1224
+ ### Use
1225
+
1226
+ ```xml
1227
+ <mjml>
1228
+ <mj-body>
1229
+ <mj-section>
1230
+ <mj-column>
1231
+ <mj-badge text="NEW" background-color="#27ae60" />
1232
+ </mj-column>
1233
+ </mj-section>
1234
+ </mj-body>
1235
+ </mjml>
1236
+ ```
1237
+
1238
+ ### Available Helper Methods
1239
+
1240
+ | Method | Description |
1241
+ |--------|-------------|
1242
+ | `render_node(node, context, parent:)` | Render a single child node |
1243
+ | `render_children(node, context, parent:)` | Render all children |
1244
+ | `resolved_attributes(node, context)` | Get merged attributes (defaults + classes + inline) |
1245
+ | `raw_inner(node)` | Get raw inner HTML (for ending-tag components) |
1246
+ | `html_inner(node)` | Get HTML-escaped inner content |
1247
+ | `style_join(hash)` | Build CSS style string from hash (skips nil values) |
1248
+ | `escape_html(value)` | HTML-escape a string |
1249
+ | `escape_attr(value)` | Attribute-escape a string |
1250
+ | `html_attrs(hash)` | Build HTML attribute string (skips nil values) |
1251
+ | `shorthand_value(parts, side)` | Parse CSS shorthand (from CssHelpers) |
1252
+ | `parse_border_width(str)` | Extract border width in px (from CssHelpers) |
1253
+
1254
+ ### Reset Custom Components
1255
+
1256
+ ```ruby
1257
+ MjmlRb.component_registry.reset!
1258
+ ```