Skip to content

Commit 4856642

Browse files
authored
Parser: Add dot_notation_tags parser option (#1436)
This pull request adds support for parsing dot-notation component tags like `<Dialog.body>` and `<Dialog.footer>` as single HTML elements. A new `dot_notation_tags` parser option enables this syntax (defaults to `false`). When enabled, the parser recognizes `<Identifier.identifier>` patterns where the first segment starts with an uppercase letter as a single tag name. Subsequent segments can be any valid identifier (uppercase or lowercase). This is similar to how JSX handles compound component patterns. For example, the following template with `dot_notation_tags: true`: ```html <Dialog.wrapper id="demo" title="Confirm"> <Dialog.body>Are you sure?</Dialog.body> <Dialog.footer> <Dialog.cancel /> <Dialog.confirm autofocus /> </Dialog.footer> </Dialog.wrapper> ``` Produces: ```js @ DocumentNode (location: (1:0)-(9:0)) └── children: (1 item) └── @ HTMLElementNode (location: (1:0)-(8:18)) ├── open_tag: │ └── @ HTMLOpenTagNode (location: (1:0)-(1:43)) │ ├── tag_opening: "<" (location: (1:0)-(1:1)) │ ├── tag_name: "Dialog.wrapper" (location: (1:1)-(1:15)) │ ├── tag_closing: ">" (location: (1:42)-(1:43)) │ ├── children: [...] │ └── is_void: false │ ├── tag_name: "Dialog.wrapper" (location: (1:1)-(1:15)) ├── body: [...] ├── close_tag: │ └── @ HTMLCloseTagNode (location: (8:0)-(8:18)) │ ├── tag_name: "Dialog.wrapper" (location: (8:2)-(8:16)) │ └── ... │ ├── is_void: false └── element_source: "HTML" ``` When `dot_notation_tags` is `true` and the first segment starts with a lowercase letter (e.g. `<dialog.Button>`), a `DotNotationCasingError` is reported: ```js @ DotNotationCasingError (location: (1:1)-(1:7)) ├── message: "Dot-notation component tags require the first segment to start with an uppercase letter. `dialog` does not start with an uppercase letter." └── segment: "dialog" (location: (1:1)-(1:7)) ``` This is heavily inspired by [`Phoenix.Component`](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) and [JSX](https://react.dev/learn/writing-markup-with-jsx).
1 parent 7527a66 commit 4856642

File tree

31 files changed

+647
-1
lines changed

31 files changed

+647
-1
lines changed

config.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,16 @@ errors:
458458

459459
fields: []
460460

461+
- name: DotNotationCasingError
462+
message:
463+
template: "Dot-notation component tags require the first segment to start with an uppercase letter. `%s` does not start with an uppercase letter."
464+
arguments:
465+
- segment->value
466+
467+
fields:
468+
- name: segment
469+
type: token
470+
461471
warnings:
462472
fields: []
463473
types: []

ext/herb/extconf.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
$VPATH << "$(srcdir)/../../src"
4747
$VPATH << "$(srcdir)/../../src/analyze"
4848
$VPATH << "$(srcdir)/../../src/analyze/action_view"
49+
$VPATH << "$(srcdir)/../../src/parser"
4950
$VPATH << "$(srcdir)/../../src/util"
5051
$VPATH << prism_src_path
5152
$VPATH << "#{prism_src_path}/util"

ext/herb/extension.c

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,12 @@ static VALUE Herb_parse(int argc, VALUE* argv, VALUE self) {
136136
}
137137
if (!NIL_P(action_view_helpers) && RTEST(action_view_helpers)) { parser_options.action_view_helpers = true; }
138138

139+
VALUE dot_notation_tags = rb_hash_lookup(options, rb_utf8_str_new_cstr("dot_notation_tags"));
140+
if (NIL_P(dot_notation_tags)) {
141+
dot_notation_tags = rb_hash_lookup(options, ID2SYM(rb_intern("dot_notation_tags")));
142+
}
143+
if (!NIL_P(dot_notation_tags) && RTEST(dot_notation_tags)) { parser_options.dot_notation_tags = true; }
144+
139145
VALUE render_nodes = rb_hash_lookup(options, rb_utf8_str_new_cstr("render_nodes"));
140146
if (NIL_P(render_nodes)) { render_nodes = rb_hash_lookup(options, ID2SYM(rb_intern("render_nodes"))); }
141147
if (!NIL_P(render_nodes) && RTEST(render_nodes)) { parser_options.render_nodes = true; }

java/herb_jni.c

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,14 @@ Java_org_herb_Herb_parse(JNIEnv* env, jclass clazz, jstring source, jobject opti
109109
parser_options.prism_program = (prismProgram == JNI_TRUE);
110110
}
111111

112+
jmethodID getDotNotationTags =
113+
(*env)->GetMethodID(env, optionsClass, "isDotNotationTags", "()Z");
114+
115+
if (getDotNotationTags != NULL) {
116+
jboolean dotNotationTags = (*env)->CallBooleanMethod(env, options, getDotNotationTags);
117+
parser_options.dot_notation_tags = (dotNotationTags == JNI_TRUE);
118+
}
119+
112120
jmethodID getHtml =
113121
(*env)->GetMethodID(env, optionsClass, "isHtml", "()Z");
114122

java/org/herb/ParserOptions.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public class ParserOptions {
1010
private boolean prismNodes = false;
1111
private boolean prismNodesDeep = false;
1212
private boolean prismProgram = false;
13+
private boolean dotNotationTags = false;
1314
private boolean html = true;
1415

1516
public ParserOptions() {}
@@ -95,6 +96,15 @@ public boolean isPrismProgram() {
9596
return prismProgram;
9697
}
9798

99+
public ParserOptions dotNotationTags(boolean value) {
100+
this.dotNotationTags = value;
101+
return this;
102+
}
103+
104+
public boolean isDotNotationTags() {
105+
return dotNotationTags;
106+
}
107+
98108
public ParserOptions html(boolean value) {
99109
this.html = value;
100110
return this;

javascript/packages/core/src/parser-options.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export interface ParseOptions {
88
prism_nodes?: boolean
99
prism_nodes_deep?: boolean
1010
prism_program?: boolean
11+
dot_notation_tags?: boolean
1112
html?: boolean
1213
}
1314

@@ -23,6 +24,7 @@ export const DEFAULT_PARSER_OPTIONS: SerializedParserOptions = {
2324
prism_nodes: false,
2425
prism_nodes_deep: false,
2526
prism_program: false,
27+
dot_notation_tags: false,
2628
html: true,
2729
}
2830

@@ -57,6 +59,9 @@ export class ParserOptions {
5759
/** Whether the full Prism ProgramNode was serialized on the DocumentNode. */
5860
readonly prism_program: boolean
5961

62+
/** Whether dot-notation component tags (e.g. Dialog.Button) are parsed as HTML elements. */
63+
readonly dot_notation_tags: boolean
64+
6065
/** Whether HTML tag parsing is enabled during parsing. When false, HTML-like content is treated as literal text. */
6166
readonly html: boolean
6267

@@ -74,6 +79,7 @@ export class ParserOptions {
7479
this.prism_nodes = options.prism_nodes ?? DEFAULT_PARSER_OPTIONS.prism_nodes
7580
this.prism_nodes_deep = options.prism_nodes_deep ?? DEFAULT_PARSER_OPTIONS.prism_nodes_deep
7681
this.prism_program = options.prism_program ?? DEFAULT_PARSER_OPTIONS.prism_program
82+
this.dot_notation_tags = options.dot_notation_tags ?? DEFAULT_PARSER_OPTIONS.dot_notation_tags
7783
this.html = options.html ?? DEFAULT_PARSER_OPTIONS.html
7884
}
7985
}

javascript/packages/linter/test/parse-cache.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ describe("ParseCache", () => {
7575
strict: true,
7676
strict_locals: false,
7777
action_view_helpers: false,
78+
dot_notation_tags: false,
7879
html: true,
7980
})
8081
})
@@ -93,6 +94,7 @@ describe("ParseCache", () => {
9394
strict: false,
9495
strict_locals: false,
9596
action_view_helpers: false,
97+
dot_notation_tags: false,
9698
html: true,
9799
})
98100
})
@@ -111,6 +113,7 @@ describe("ParseCache", () => {
111113
strict: true,
112114
strict_locals: false,
113115
action_view_helpers: false,
116+
dot_notation_tags: false,
114117
html: true,
115118
})
116119
})
@@ -129,6 +132,7 @@ describe("ParseCache", () => {
129132
strict: false,
130133
strict_locals: false,
131134
action_view_helpers: false,
135+
dot_notation_tags: false,
132136
html: true,
133137
})
134138
})

javascript/packages/node/binding.gyp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"./extension/libherb/lexer_peek_helpers.c",
4646
"./extension/libherb/lexer.c",
4747
"./extension/libherb/location.c",
48+
"./extension/libherb/parser/dot_notation.c",
4849
"./extension/libherb/parser_helpers.c",
4950
"./extension/libherb/parser_match_tags.c",
5051
"./extension/libherb/parser.c",

playground/index.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,16 @@
501501
/>
502502
<span class="select-none">HTML</span>
503503
</label>
504+
505+
<label class="flex items-center gap-1.5 text-gray-300 text-sm" title="Enable dot-notation component tags like &lt;Dialog.Button&gt; — requires each segment to start with an uppercase letter">
506+
<input
507+
type="checkbox"
508+
data-option="dot_notation_tags"
509+
data-action="change->playground#onOptionChange"
510+
class="rounded border-gray-600 text-green-600 focus:ring-green-500 bg-gray-700"
511+
/>
512+
<span class="select-none">Dot-notation tags</span>
513+
</label>
504514
</div>
505515

506516
<pre

playground/src/controllers/playground_controller.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1271,6 +1271,7 @@ export default class extends Controller {
12711271
prism_program: false,
12721272
prism_nodes: false,
12731273
prism_nodes_deep: false,
1274+
dot_notation_tags: false,
12741275
html: true,
12751276
}
12761277

0 commit comments

Comments
 (0)