);
+ return done;
+}
+
+// Dynamic tags: "<$tag" and "<{expr}" in operand position are markup
+// ("$i--<$n" above is the operator-position inverse, still a comparison).
+function never_runs_dynamic() {
+ $tag = 'div';
+ $x = <$tag class="a">hi$tag>;
+ $y = <{$tag . 'x'}>hi>;
+ take(<{pick()}/>);
+ return <$tag/>;
+}
+echo "parsed\n";
+?>
+--EXPECT--
+bool(true)
+bool(true)
+int(4)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(false)
+bool(false)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+parsed
diff --git a/Zend/zend_ast.c b/Zend/zend_ast.c
index d3ce419c737e..aef7325f3d10 100644
--- a/Zend/zend_ast.c
+++ b/Zend/zend_ast.c
@@ -2574,6 +2574,16 @@ static ZEND_COLD void zend_ast_export_ex(smart_str *str, zend_ast *ast, int prio
}
smart_str_appends(str, "::class");
break;
+ case ZEND_AST_CALLABLE_NAME:
+ /* Only produced by markup component lowering, which is a call and so
+ * never reaches a const-expr export; handled for completeness. It
+ * compiles to a constant function name (or candidate-name list, see
+ * zend_compile_callable_name), so export the as-written name quoted
+ * rather than as a bare constant. */
+ smart_str_appendc(str, '\'');
+ zend_ast_export_ns_name(str, ast->child[0], 0, indent);
+ smart_str_appendc(str, '\'');
+ break;
case ZEND_AST_ASSIGN: BINARY_OP(" = ", 90, 91, 90);
case ZEND_AST_ASSIGN_REF: BINARY_OP(" =& ", 90, 91, 90);
case ZEND_AST_ASSIGN_OP:
diff --git a/Zend/zend_ast.h b/Zend/zend_ast.h
index 24b77d7d3493..aa59f1e2b720 100644
--- a/Zend/zend_ast.h
+++ b/Zend/zend_ast.h
@@ -111,6 +111,9 @@ enum _zend_ast_kind {
ZEND_AST_BREAK,
ZEND_AST_CONTINUE,
ZEND_AST_PROPERTY_HOOK_SHORT_BODY,
+ /* Native markup: declared last in its group so it appends to the enum
+ * instead of renumbering the kinds after it (which would break ext/php-ast). */
+ ZEND_AST_CALLABLE_NAME,
/* 2 child nodes */
ZEND_AST_DIM = 2 << ZEND_AST_NUM_CHILDREN_SHIFT,
diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c
index 9f8ebad8ab29..aa1cf4f4c67e 100644
--- a/Zend/zend_compile.c
+++ b/Zend/zend_compile.c
@@ -11425,6 +11425,40 @@ static void zend_compile_class_name(znode *result, const zend_ast *ast) /* {{{ *
}
/* }}} */
+/* Native markup (RFC: Native Markup Expressions). Resolve a component tag's name
+ * against the *function* import table (`use function`) and current namespace,
+ * yielding a compile-time constant: a single name, or a candidate list (current
+ * namespace, then global) when an unqualified name in a namespace needs PHP's
+ * usual global fallback. This is the function-table analogue of
+ * ZEND_AST_CLASS_NAME: a `` tag is lowered to two candidate names — a
+ * class one (ZEND_AST_CLASS_NAME) and a function one (this) — so the runtime's
+ * class-then-function dispatch can honor `use` and `use function` independently,
+ * exactly as PHP resolves class vs call names. See zend_ast_create_markup_component
+ * in Zend/zend_markup.c. */
+static void zend_compile_callable_name(znode *result, const zend_ast *ast) /* {{{ */
+{
+ zend_ast *name_ast = ast->child[0];
+ zend_string *name = zend_ast_get_str(name_ast);
+ bool is_fully_qualified;
+ zend_string *resolved =
+ zend_resolve_function_name(name, name_ast->attr, &is_fully_qualified);
+
+ result->op_type = IS_CONST;
+ if (!is_fully_qualified && !zend_string_equals(resolved, name)) {
+ /* An unqualified name inside a namespace resolves like an ordinary
+ * call: current namespace first, then the global namespace. Pass both
+ * candidates, in that order, for the runtime to try. */
+ zval candidates;
+ array_init_size(&candidates, 2);
+ add_next_index_str(&candidates, resolved);
+ add_next_index_str(&candidates, zend_string_copy(name));
+ ZVAL_COPY_VALUE(&result->u.constant, &candidates);
+ } else {
+ ZVAL_STR(&result->u.constant, resolved);
+ }
+}
+/* }}} */
+
static zend_op *zend_compile_rope_add_ex(zend_op *opline, znode *result, uint32_t num, znode *elem_node) /* {{{ */
{
if (num == 0) {
@@ -12223,6 +12257,9 @@ static void zend_compile_expr_inner(znode *result, zend_ast *ast) /* {{{ */
case ZEND_AST_CLASS_NAME:
zend_compile_class_name(result, ast);
return;
+ case ZEND_AST_CALLABLE_NAME:
+ zend_compile_callable_name(result, ast);
+ return;
case ZEND_AST_ENCAPS_LIST:
zend_compile_encaps_list(result, ast);
return;
diff --git a/Zend/zend_globals.h b/Zend/zend_globals.h
index 61499c0cc23d..062cf62f3a9c 100644
--- a/Zend/zend_globals.h
+++ b/Zend/zend_globals.h
@@ -402,6 +402,11 @@ struct _zend_php_scanner_globals {
/* initial string length after scanning to first variable */
int scanned_string_len;
+ /* Last significant token returned, used to decide whether a bare "<" in
+ * ST_IN_SCRIPTING is in operand position (begins native markup) or operator
+ * position (a comparison). See zend_language_scanner.l. */
+ int last_token;
+
/* hooks */
void (*on_event)(
zend_php_scanner_event event, int token, int line,
diff --git a/Zend/zend_language_parser.y b/Zend/zend_language_parser.y
index b4dda00404ea..e6c2f5c82c8d 100644
--- a/Zend/zend_language_parser.y
+++ b/Zend/zend_language_parser.y
@@ -26,6 +26,7 @@
#include "zend_constants.h"
#include "zend_language_scanner.h"
#include "zend_exceptions.h"
+#include "zend_markup.h"
#define YYSIZE_T size_t
#define yytnamerr zend_yytnamerr
@@ -250,6 +251,21 @@ static YYSIZE_T zend_yytnamerr(char*, const char*);
/* Token used to force a parse error from the lexer */
%token T_ERROR
+/* Native markup tokens. Declared last so they append to the token enum:
+ * inserting them mid-list would renumber every later token (T_INLINE_HTML
+ * onward), breaking code that hardcodes token integers. */
+%token T_MARKUP_NAME "markup tag name"
+%token T_MARKUP_SLOT_NAME "markup slot name"
+%token T_MARKUP_TEXT "markup text"
+%token T_MARKUP_ATTR_VALUE "markup attribute value"
+%token T_MARKUP_COMMENT "markup comment"
+%token T_MARKUP_OPEN "markup tag start"
+%token T_MARKUP_CLOSE_OPEN "markup closing tag start"
+%token T_MARKUP_TAG_END "markup tag end"
+%token T_MARKUP_SELF_CLOSE "markup self-close"
+%token T_MARKUP_INTERP_START "markup interpolation start"
+%token T_MARKUP_DOCTYPE "markup doctype"
+
%type top_statement namespace_name name statement function_declaration_statement
%type class_declaration_statement trait_declaration_statement legacy_namespace_name
%type interface_declaration_statement interface_extends_list
@@ -283,6 +299,8 @@ static YYSIZE_T zend_yytnamerr(char*, const char*);
%type attributed_statement attributed_top_statement attributed_class_statement attributed_parameter
%type attribute_decl attribute attributes attribute_group namespace_declaration_name
%type match match_arm_list non_empty_match_arm_list match_arm match_arm_cond_list
+%type markup_element markup_children markup_child markup_slot
+%type markup_attributes markup_attribute
%type enum_declaration_statement enum_backing_type enum_case enum_case_expr
%type function_name non_empty_member_modifiers
%type property_hook property_hook_list optional_property_hook_list hooked_property property_hook_body
@@ -1249,9 +1267,66 @@ new_non_dereferenceable:
{ $$ = zend_ast_create(ZEND_AST_NEW, $2, zend_ast_create_list(0, ZEND_AST_ARG_LIST)); }
;
+markup_element:
+ T_MARKUP_OPEN T_MARKUP_NAME markup_attributes T_MARKUP_TAG_END markup_children T_MARKUP_CLOSE_OPEN T_MARKUP_NAME T_MARKUP_TAG_END
+ { $$ = zend_ast_create_markup_checked($2, $3, $5, $7); if (!$$) { YYERROR; } }
+ | T_MARKUP_OPEN T_MARKUP_NAME markup_attributes T_MARKUP_SELF_CLOSE
+ { $$ = zend_ast_create_markup_element($2, $3, zend_ast_create_list(0, ZEND_AST_ARRAY)); if (!$$) { YYERROR; } }
+ | T_MARKUP_OPEN T_MARKUP_TAG_END markup_children T_MARKUP_CLOSE_OPEN T_MARKUP_TAG_END
+ { $$ = zend_ast_create_markup_element(NULL, NULL, $3); if (!$$) { YYERROR; } }
+ | T_MARKUP_OPEN T_VARIABLE markup_attributes T_MARKUP_TAG_END markup_children T_MARKUP_CLOSE_OPEN T_VARIABLE T_MARKUP_TAG_END
+ { $$ = zend_ast_create_markup_dynamic_checked($2, $3, $5, $7); if (!$$) { YYERROR; } }
+ | T_MARKUP_OPEN T_VARIABLE markup_attributes T_MARKUP_SELF_CLOSE
+ { $$ = zend_ast_create_markup_dynamic($2, $3, zend_ast_create_list(0, ZEND_AST_ARRAY)); }
+ | T_MARKUP_OPEN T_MARKUP_INTERP_START expr '}' markup_attributes T_MARKUP_TAG_END markup_children T_MARKUP_CLOSE_OPEN T_MARKUP_TAG_END
+ { $$ = zend_ast_create_markup_dynamic_expr($3, $5, $7); }
+ | T_MARKUP_OPEN T_MARKUP_INTERP_START expr '}' markup_attributes T_MARKUP_SELF_CLOSE
+ { $$ = zend_ast_create_markup_dynamic_expr($3, $5, zend_ast_create_list(0, ZEND_AST_ARRAY)); }
+;
+
+markup_attributes:
+ %empty { $$ = zend_ast_create_list(0, ZEND_AST_ARRAY); }
+ | markup_attributes markup_attribute { $$ = zend_ast_list_add($1, $2); }
+;
+
+markup_attribute:
+ T_MARKUP_NAME '=' T_MARKUP_ATTR_VALUE
+ { $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, $3, $1); }
+ | T_MARKUP_NAME '=' T_MARKUP_INTERP_START expr '}'
+ { $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, $4, $1); }
+ | T_MARKUP_NAME
+ { zval t; ZVAL_TRUE(&t); $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, zend_ast_create_zval(&t), $1); }
+ | T_MARKUP_INTERP_START T_ELLIPSIS expr '}'
+ { $$ = zend_ast_create(ZEND_AST_UNPACK, $3); }
+;
+
+markup_children:
+ %empty { $$ = zend_ast_create_list(0, ZEND_AST_ARRAY); }
+ | markup_children markup_child { $$ = zend_ast_list_add($1, $2); }
+;
+
+markup_child:
+ T_MARKUP_TEXT { $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, $1, NULL); }
+ | T_MARKUP_COMMENT { $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, zend_ast_create_markup_raw($1), NULL); }
+ | T_MARKUP_DOCTYPE { $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, zend_ast_create_markup_raw($1), NULL); }
+ | T_MARKUP_INTERP_START expr '}' { $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, $2, NULL); }
+ | T_MARKUP_INTERP_START '}' { zval n; ZVAL_NULL(&n); $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, zend_ast_create_zval(&n), NULL); }
+ | markup_element { $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, $1, NULL); }
+ | markup_slot { $$ = $1; }
+;
+
+markup_slot:
+ T_MARKUP_OPEN T_MARKUP_SLOT_NAME markup_attributes T_MARKUP_TAG_END markup_children T_MARKUP_CLOSE_OPEN T_MARKUP_SLOT_NAME T_MARKUP_TAG_END
+ { $$ = zend_ast_create_markup_slot($2, $3, $5, $7); if (!$$) { YYERROR; } }
+ | T_MARKUP_OPEN T_MARKUP_SLOT_NAME markup_attributes T_MARKUP_SELF_CLOSE
+ { $$ = zend_ast_create_markup_slot($2, $3, NULL, NULL); if (!$$) { YYERROR; } }
+;
+
expr:
variable
{ $$ = $1; }
+ | markup_element
+ { $$ = $1; }
| T_LIST '(' array_pair_list ')' '=' expr
{ $3->attr = ZEND_ARRAY_SYNTAX_LIST; $$ = zend_ast_create(ZEND_AST_ASSIGN, $3, $6); }
| '[' array_pair_list ']' '=' expr
diff --git a/Zend/zend_language_scanner.h b/Zend/zend_language_scanner.h
index e502d91411b5..1415e2fa991d 100644
--- a/Zend/zend_language_scanner.h
+++ b/Zend/zend_language_scanner.h
@@ -30,6 +30,7 @@ typedef struct _zend_lex_state {
unsigned char *yy_marker;
unsigned char *yy_limit;
int yy_state;
+ int last_token; /* operand-vs-operator position for native markup */
zend_stack state_stack;
zend_ptr_stack heredoc_label_stack;
zend_stack nest_location_stack; /* for syntax error reporting */
diff --git a/Zend/zend_language_scanner.l b/Zend/zend_language_scanner.l
index 07f2d44cb5c6..490715becce8 100644
--- a/Zend/zend_language_scanner.l
+++ b/Zend/zend_language_scanner.l
@@ -46,6 +46,7 @@
#include "zend_strtod.h"
#include "zend_exceptions.h"
#include "zend_virtual_cwd.h"
+#include "zend_markup.h"
#define YYCTYPE unsigned char
#define YYFILL(n) { if ((YYCURSOR + n) >= (YYLIMIT + ZEND_MMAP_AHEAD)) { return 0; } }
@@ -194,6 +195,7 @@ void startup_scanner(void)
zend_stack_init(&SCNG(nest_location_stack), sizeof(zend_nest_location));
zend_ptr_stack_init(&SCNG(heredoc_label_stack));
SCNG(heredoc_scan_ahead) = 0;
+ SCNG(last_token) = 0;
}
static void heredoc_label_dtor(zend_heredoc_label *heredoc_label) {
@@ -233,6 +235,7 @@ ZEND_API void zend_save_lexical_state(zend_lex_state *lex_state)
lex_state->in = SCNG(yy_in);
lex_state->yy_state = YYSTATE;
+ lex_state->last_token = SCNG(last_token);
lex_state->filename = CG(compiled_filename);
lex_state->lineno = CG(zend_lineno);
CG(compiled_filename) = NULL;
@@ -273,6 +276,7 @@ ZEND_API void zend_restore_lexical_state(zend_lex_state *lex_state)
SCNG(yy_in) = lex_state->in;
YYSETCONDITION(lex_state->yy_state);
+ SCNG(last_token) = lex_state->last_token;
CG(zend_lineno) = lex_state->lineno;
zend_restore_compiled_filename(lex_state->filename);
@@ -543,6 +547,7 @@ ZEND_API zend_result open_file_for_scanning(zend_file_handle *file_handle)
/* Reset the scanner for scanning the new file */
SCNG(yy_in) = file_handle;
SCNG(yy_start) = NULL;
+ SCNG(last_token) = 0;
if (size != (size_t)-1) {
if (CG(multibyte)) {
@@ -744,6 +749,7 @@ ZEND_API void zend_prepare_string_for_scanning(zval *str, zend_string *filename)
SCNG(yy_in) = NULL;
SCNG(yy_start) = NULL;
+ SCNG(last_token) = 0;
buf = Z_STRVAL_P(str);
size = old_len;
@@ -1357,6 +1363,71 @@ static zend_result check_nesting_at_end(void)
} \
} while (0)
+/* Native markup (RFC: Native Markup Expressions). A bare "<" in ST_IN_SCRIPTING
+ * begins a markup expression only in *operand* position (where a value, not an
+ * operator, is expected). We approximate operand-vs-operator position from the
+ * last significant token: if it can end an operand, the "<" is a comparison;
+ * otherwise a value is expected and "" begins markup. Because "<"
+ * immediately followed by a letter or ">" in operand position is always a syntax
+ * error today, reclaiming it is backward compatible.
+ *
+ * Known limitation: "}" must stay an operand ender (match/array/interpolation
+ * results), so markup as an expression statement directly after a block —
+ * `if ($c) { ... }
hi
;` — lexes as a comparison and fails to parse.
+ * Assign or echo the markup instead. Contributors adding a new token that can
+ * END an expression operand must add it to this switch, or "NEWTOKEN < X"
+ * silently becomes a markup parse error. */
+static bool zend_markup_operand_position(void)
+{
+ switch (SCNG(last_token)) {
+ /* tokens that END an operand → the following "<" is a comparison */
+ case T_VARIABLE:
+ case T_LNUMBER:
+ case T_DNUMBER:
+ case T_NUM_STRING:
+ case T_CONSTANT_ENCAPSED_STRING:
+ case T_STRING:
+ case T_NAME_FULLY_QUALIFIED:
+ case T_NAME_QUALIFIED:
+ case T_NAME_RELATIVE:
+ case T_LINE:
+ case T_FILE:
+ case T_DIR:
+ case T_CLASS_C:
+ case T_TRAIT_C:
+ case T_METHOD_C:
+ case T_FUNC_C:
+ case T_NS_C:
+ case T_PROPERTY_C:
+ case T_END_HEREDOC:
+ /* postfix increment/decrement and the `class` keyword in `Foo::class`
+ * also END an operand: `$i++ 5` and
+ * `yield < LIM` are valid PHP, so a "<" after `yield` stays a
+ * comparison and BC is preserved. The cost is that yielding markup
+ * needs parentheses — `yield ()` — which the RFC documents.
+ * (`yield from` requires an operand, so markup after it just works.) */
+ case T_YIELD:
+ case ')':
+ case ']':
+ case '}':
+ case '"': /* doubles as the closing quote: string states intervene */
+ case '`':
+ return false;
+ default:
+ return true;
+ }
+}
+
int ZEND_FASTCALL lex_scan(zval *zendlval, zend_parser_stack_elem *elem)
{
int token;
@@ -1842,7 +1913,7 @@ OPTIONAL_WHITESPACE_OR_COMMENTS ({WHITESPACE}|{MULTI_LINE_COMMENT}|{SINGLE_LINE_
RETURN_TOKEN(T_IS_EQUAL);
}
-"!="|"<>" {
+"!=" {
RETURN_TOKEN(T_IS_NOT_EQUAL);
}
@@ -1977,6 +2048,235 @@ OPTIONAL_WHITESPACE_OR_COMMENTS ({WHITESPACE}|{MULTI_LINE_COMMENT}|{SINGLE_LINE_
}
+/* Native markup (RFC: Native Markup Expressions). In operand position, "" a fragment; in operator position both keep
+ * their existing meaning ("<" comparison, "<>" not-equal). Nested elements
+ * inside markup content use a bare "<" handled by ST_MARKUP_CONTENT. */
+"<""\\"?[a-zA-Z] {
+ /* ""<>" {
+ if (zend_markup_operand_position()) {
+ yyless(1);
+ yy_push_state(ST_MARKUP_TAG);
+ RETURN_TOKEN(T_MARKUP_OPEN);
+ }
+ RETURN_TOKEN(T_IS_NOT_EQUAL);
+}
+
+"<""$"{LABEL} {
+ /* "<$tag": a dynamic tag whose variable's runtime value names the element
+ * or component (<$tag>…$tag>). Only markup in operand position; in
+ * operator position "<" keeps its comparison meaning ("$a <$b"). */
+ yyless(1);
+ if (zend_markup_operand_position()) {
+ yy_push_state(ST_MARKUP_TAG);
+ RETURN_TOKEN(T_MARKUP_OPEN);
+ }
+ RETURN_TOKEN('<');
+}
+
+"<{" {
+ /* "<{expr}": a dynamic tag computed by an arbitrary expression, closed
+ * anonymously (">") or self-closed. The "{" is left for ST_MARKUP_TAG's
+ * own interpolation rule. Only markup in operand position; in operator
+ * position "<" keeps its comparison meaning. */
+ yyless(1);
+ if (zend_markup_operand_position()) {
+ yy_push_state(ST_MARKUP_TAG);
+ RETURN_TOKEN(T_MARKUP_OPEN);
+ }
+ RETURN_TOKEN('<');
+}
+
+{WHITESPACE} {
+ /* Count newlines and emit T_WHITESPACE in tokenizer mode, like ordinary
+ * whitespace (a bare restart would freeze line numbers and swallow the
+ * whitespace from token_get_all). */
+ goto return_whitespace;
+}
+
+"slot:"[a-zA-Z][a-zA-Z0-9_.-]* {
+ /* a named-slot block …; strip the "slot:" prefix */
+ RETURN_TOKEN_WITH_STR(T_MARKUP_SLOT_NAME, sizeof("slot:") - 1);
+}
+
+"\\"?[a-zA-Z][a-zA-Z0-9_.:-]*("\\"[a-zA-Z][a-zA-Z0-9_.:-]*)* {
+ /* A tag name, optionally namespace-qualified () or fully
+ * qualified (<\App\Card/>). A name containing "\" always dispatches as a
+ * component (see zend_markup_name_is_component in Zend/zend_markup.c). */
+ RETURN_TOKEN_WITH_STR(T_MARKUP_NAME, 0);
+}
+
+[:@][a-zA-Z][a-zA-Z0-9_.:-]* {
+ /* An attribute name with a ":" or "@" shorthand prefix (Vue/Alpine:
+ * :class, @click). Attribute position only: this can never lex as a tag
+ * name, because every markup-open rule requires a letter, "\", "$", "{"
+ * or ">" after "<", and ST_MARKUP_CLOSE is excluded here. */
+ RETURN_TOKEN_WITH_STR(T_MARKUP_NAME, 0);
+}
+
+"$"{LABEL} {
+ /* A dynamic tag name (<$tag>…$tag>): an ordinary variable whose runtime
+ * value decides element vs component (see Html\render_dynamic). */
+ RETURN_TOKEN_WITH_STR(T_VARIABLE, 1);
+}
+
+"=" {
+ RETURN_TOKEN('=');
+}
+
+"\"" [^"]* "\"" {
+ /* Literal double-quoted attribute value (escaped at render time). The
+ * quotes stay part of the token text (yyleng untouched) so token_get_all
+ * round-trips; only the zval payload is the unquoted interior, with
+ * character references decoded for the compiler. */
+ HANDLE_NEWLINES(yytext, yyleng);
+ zend_copy_value(zendlval, yytext + 1, yyleng - 2);
+ if (PARSER_MODE() && !SCNG(on_event) && Z_TYPE_P(zendlval) == IS_STRING) {
+ ZVAL_STR(zendlval, zend_markup_decode_entities(Z_STR_P(zendlval)));
+ }
+ RETURN_TOKEN_WITH_VAL(T_MARKUP_ATTR_VALUE);
+}
+
+"{" {
+ yy_push_state(ST_IN_SCRIPTING);
+ enter_nesting('{');
+ RETURN_TOKEN(T_MARKUP_INTERP_START);
+}
+
+"/>" {
+ yy_pop_state();
+ RETURN_TOKEN(T_MARKUP_SELF_CLOSE);
+}
+
+">" {
+ BEGIN(ST_MARKUP_CONTENT);
+ RETURN_TOKEN(T_MARKUP_TAG_END);
+}
+
+">" {
+ yy_pop_state();
+ RETURN_TOKEN(T_MARKUP_TAG_END);
+}
+
+{ANY_CHAR} {
+ if (YYCURSOR > YYLIMIT) {
+ RETURN_END_TOKEN;
+ }
+ RETURN_TOKEN(T_ERROR);
+}
+
+"";
+ * content is literal (no interpolation). */
+ while (YYCURSOR < YYLIMIT) {
+ if (YYCURSOR + 2 < YYLIMIT && YYCURSOR[0] == '-' && YYCURSOR[1] == '-' && YYCURSOR[2] == '>') {
+ YYCURSOR += 3;
+ yyleng = YYCURSOR - SCNG(yy_text);
+ HANDLE_NEWLINES(yytext, yyleng);
+ RETURN_TOKEN_WITH_STR(T_MARKUP_COMMENT, 0);
+ }
+ YYCURSOR++;
+ }
+ RETURN_TOKEN(T_ERROR);
+}
+
+"";
+ * content is literal (no interpolation). */
+ while (YYCURSOR < YYLIMIT) {
+ if (*YYCURSOR == '>') {
+ YYCURSOR++;
+ yyleng = YYCURSOR - SCNG(yy_text);
+ HANDLE_NEWLINES(yytext, yyleng);
+ RETURN_TOKEN_WITH_STR(T_MARKUP_DOCTYPE, 0);
+ }
+ YYCURSOR++;
+ }
+ RETURN_TOKEN(T_ERROR);
+}
+
+"" shortcut (they end at "]]>" and may contain ">"), so
+ * reject with a targeted message rather than the generic stray-"<" one. */
+ if (PARSER_MODE()) {
+ zend_throw_exception(zend_ce_parse_error,
+ "Unsupported markup declaration; only comments and are supported", 0);
+ }
+ RETURN_TOKEN(T_ERROR);
+}
+
+"" {
+ BEGIN(ST_MARKUP_CLOSE);
+ RETURN_TOKEN(T_MARKUP_CLOSE_OPEN);
+}
+
+"<" {
+ /* A nested tag ("
"); "" and "` comments, which are emitted as literal HTML (RFC §7). */
+zend_ast *zend_ast_create_markup_raw(zend_ast *str)
+{
+ zend_ast *fn = zend_ast_markup_fq_name(ZEND_STRL(ZEND_MARKUP_RAW_FQ));
+ return zend_ast_create(ZEND_AST_CALL, fn,
+ zend_ast_create_list(1, ZEND_AST_ARG_LIST, str));
+}
+
+/* Build a `…` block (RFC §6) as a *keyed* array element
+ * `'name' => new \Html\Fragment([children])`. The component builder later routes
+ * keyed elements to render_component's namedSlots argument. `close` is consumed. */
+zend_ast *zend_ast_create_markup_slot(zend_ast *open, zend_ast *attrs, zend_ast *children, zend_ast *close)
+{
+ if (close != NULL) {
+ if (!zend_string_equals(Z_STR_P(zend_ast_get_zval(open)), Z_STR_P(zend_ast_get_zval(close)))) {
+ zend_throw_exception_ex(zend_ce_compile_error, 0,
+ "Mismatched markup closing tag: expected , found ",
+ Z_STRVAL_P(zend_ast_get_zval(open)), Z_STRVAL_P(zend_ast_get_zval(close)));
+ zend_ast_destroy(open);
+ zend_ast_destroy(attrs);
+ if (children != NULL) {
+ zend_ast_destroy(children);
+ }
+ zend_ast_destroy(close);
+ return NULL;
+ }
+ zend_ast_destroy(close);
+ }
+ /* A slot block routes body content only; silently discarding written
+ * attributes would hide a mistake. */
+ if (attrs != NULL && zend_ast_get_list(attrs)->children > 0) {
+ zend_throw_exception_ex(zend_ce_compile_error, 0,
+ "Markup slot cannot have attributes",
+ Z_STRVAL_P(zend_ast_get_zval(open)));
+ zend_ast_destroy(open);
+ zend_ast_destroy(attrs);
+ if (children != NULL) {
+ zend_ast_destroy(children);
+ }
+ return NULL;
+ }
+ if (attrs != NULL) {
+ zend_ast_destroy(attrs);
+ }
+ return zend_ast_create(ZEND_AST_ARRAY_ELEM, zend_ast_wrap_fragment(children), open);
+}
+
+/* Partition a markup body into loose content and blocks: keyed
+ * elements are named slots, everything else forms the anonymous slot (RFC §6).
+ * Consumes `children` (may be NULL); both outputs are fresh ZEND_AST_ARRAY
+ * lists with ZEND_ARRAY_SYNTAX_SHORT set. */
+static void zend_markup_partition_children(zend_ast *children, zend_ast **anon_out, zend_ast **named_out)
+{
+ zend_ast *anon = zend_ast_create_list(0, ZEND_AST_ARRAY);
+ zend_ast *named_slots = zend_ast_create_list(0, ZEND_AST_ARRAY);
+
+ if (children != NULL) {
+ zend_ast_list *clist = zend_ast_get_list(children);
+ for (uint32_t i = 0; i < clist->children; i++) {
+ zend_ast *elem = clist->child[i];
+ if (elem != NULL && elem->kind == ZEND_AST_ARRAY_ELEM && elem->child[1] != NULL) {
+ named_slots = zend_ast_list_add(named_slots, elem);
+ } else {
+ anon = zend_ast_list_add(anon, elem);
+ }
+ }
+ /* Detach the moved elements before freeing the now-empty container. */
+ clist->children = 0;
+ zend_ast_destroy(children);
+ }
+ anon->attr = ZEND_ARRAY_SYNTAX_SHORT;
+ named_slots->attr = ZEND_ARRAY_SYNTAX_SHORT;
+
+ *anon_out = anon;
+ *named_out = named_slots;
+}
+
+/* Lower a component tag (capitalized name, namespace-qualified name, or
+ * "Class::method") into a call to
+ * \Html\render_component(component, props, slot, namedSlots, functionComponent)
+ * (RFC §3). Attributes become the props array; loose body content becomes a
+ * single \Html\Fragment passed as the anonymous slot, and blocks
+ * (keyed elements) are partitioned into the namedSlots array.
+ *
+ * Component resolution is two-stage. A bare tag could name either a class
+ * component or a function component, which PHP resolves through *separate* import
+ * tables (`use` vs `use function`). So this pass resolves the name *both* ways —
+ * a class candidate (ZEND_AST_CLASS_NAME, the `component` argument) and a function
+ * candidate (ZEND_AST_CALLABLE_NAME, the `functionComponent` argument) — and the
+ * runtime applies the dispatch order (Class::method, then Htmlable class, then
+ * userland function) across the two, so `use App\C;` resolves a class and
+ * `use function App\C;` resolves a function, exactly as ordinary PHP does. A
+ * leading "\" makes both candidates fully qualified. A "Class::method" tag is
+ * unambiguously a static method, so it has no function candidate. See
+ * PHP_FUNCTION(Html_render_component) in ext/html/html.c for the second stage. */
+static zend_ast *zend_ast_create_markup_component(zend_ast *name, zend_ast *attrs, zend_ast *children)
+{
+ zend_string *tag = zend_ast_get_str(name);
+ const char *sep = strstr(ZSTR_VAL(tag), "::");
+ /* A leading "\" is a fully-qualified reference; otherwise resolve the name
+ * (unqualified or qualified) against the current use/namespace. */
+ uint32_t name_type = (ZSTR_LEN(tag) > 0 && ZSTR_VAL(tag)[0] == '\\')
+ ? ZEND_NAME_FQ : ZEND_NAME_NOT_FQ;
+ zend_ast *component_name;
+ zend_ast *function_name;
+
+ if (sep != NULL) {
+ /* "Class::method": resolve the class part as a class, then append
+ * "::method". A static method is never a plain function. */
+ size_t class_len = sep - ZSTR_VAL(tag);
+ zend_ast *class_name = zend_ast_create_zval_from_str(
+ zend_string_init(ZSTR_VAL(tag), class_len, 0));
+ class_name->attr = name_type;
+
+ zval suffix;
+ ZVAL_STR(&suffix, zend_string_init(sep, ZSTR_LEN(tag) - class_len, 0));
+ component_name = zend_ast_create_concat_op(
+ zend_ast_create(ZEND_AST_CLASS_NAME, class_name),
+ zend_ast_create_zval(&suffix));
+
+ zval null_zv;
+ ZVAL_NULL(&null_zv);
+ function_name = zend_ast_create_zval(&null_zv);
+ } else {
+ /* Resolve the name both as a class (honors `use`) and as a function
+ * (honors `use function`); the runtime picks between them. */
+ zend_ast *class_name = zend_ast_create_zval_from_str(zend_string_copy(tag));
+ class_name->attr = name_type;
+ component_name = zend_ast_create(ZEND_AST_CLASS_NAME, class_name);
+
+ zend_ast *func_name = zend_ast_create_zval_from_str(zend_string_copy(tag));
+ func_name->attr = name_type;
+ function_name = zend_ast_create(ZEND_AST_CALLABLE_NAME, func_name);
+ }
+ zend_ast_destroy(name);
+
+ if (attrs == NULL) {
+ attrs = zend_ast_create_list(0, ZEND_AST_ARRAY);
+ }
+ attrs->attr = ZEND_ARRAY_SYNTAX_SHORT;
+
+ /* Partition the body: keyed elements are blocks (named slots);
+ * everything else is loose content forming the anonymous slot (RFC §6). */
+ zend_ast *anon, *named_slots;
+ zend_markup_partition_children(children, &anon, &named_slots);
+
+ /* Anonymous slot: a single Html\Fragment, or null when there is no loose body. */
+ zend_ast *slot;
+ if (zend_ast_get_list(anon)->children > 0) {
+ slot = zend_ast_wrap_fragment(anon);
+ } else {
+ zend_ast_destroy(anon);
+ zval null_zv;
+ ZVAL_NULL(&null_zv);
+ slot = zend_ast_create_zval(&null_zv);
+ }
+
+ zend_ast *fn = zend_ast_markup_fq_name(ZEND_STRL(ZEND_MARKUP_COMPONENT_FQ));
+
+ zend_ast *args = zend_ast_create_list(0, ZEND_AST_ARG_LIST);
+ args = zend_ast_list_add(args, component_name);
+ args = zend_ast_list_add(args, attrs);
+ args = zend_ast_list_add(args, slot);
+ args = zend_ast_list_add(args, named_slots);
+ args = zend_ast_list_add(args, function_name);
+
+ return zend_ast_create(ZEND_AST_CALL, fn, args);
+}
+
+zend_ast *zend_ast_create_markup_element(zend_ast *name, zend_ast *attrs, zend_ast *children)
+{
+ zend_ast *class_name, *args, *result;
+
+ /* The grammar reduces a markup element only once its closing tag has been
+ * scanned, so nodes created here would inherit the *close* line. Stamp the
+ * lowered expression with the line the element opens on instead, so errors
+ * in a multi-line element report like any multi-line call. The tag-name
+ * token carries that line; a fragment has none, but its children list is
+ * created as content begins. */
+ uint32_t lineno = (name != NULL)
+ ? zend_ast_get_lineno(name) : zend_ast_get_lineno(children);
+
+ /* A capitalized name, or one containing "::", is a component (RFC §3). */
+ if (name != NULL && zend_markup_name_is_component(zend_ast_get_str(name))) {
+ result = zend_ast_create_markup_component(name, attrs, children);
+ result->lineno = lineno;
+ return result;
+ }
+
+ /* blocks lower to keyed children and are only routed by the
+ * component builder above; in a plain element or fragment one would slip
+ * into the children array as a stray string key. */
+ zend_ast_list *clist = zend_ast_get_list(children);
+ for (uint32_t i = 0; i < clist->children; i++) {
+ zend_ast *elem = clist->child[i];
+ if (elem != NULL && elem->kind == ZEND_AST_ARRAY_ELEM && elem->child[1] != NULL) {
+ zend_throw_exception_ex(zend_ce_compile_error, 0,
+ "Markup slot is only allowed in a component body",
+ Z_STRVAL_P(zend_ast_get_zval(elem->child[1])));
+ if (name != NULL) {
+ zend_ast_destroy(name);
+ }
+ if (attrs != NULL) {
+ zend_ast_destroy(attrs);
+ }
+ zend_ast_destroy(children);
+ return NULL;
+ }
+ }
+
+ children->attr = ZEND_ARRAY_SYNTAX_SHORT;
+
+ args = zend_ast_create_list(0, ZEND_AST_ARG_LIST);
+
+ if (name == NULL) {
+ /* A fragment has no attributes (the grammar forbids them on `<>`). */
+ if (attrs != NULL) {
+ zend_ast_destroy(attrs);
+ }
+ class_name = zend_ast_markup_fq_name(ZEND_STRL(ZEND_MARKUP_FRAGMENT_FQ));
+ args = zend_ast_list_add(args, children);
+ } else {
+ if (attrs == NULL) {
+ attrs = zend_ast_create_list(0, ZEND_AST_ARRAY);
+ }
+ attrs->attr = ZEND_ARRAY_SYNTAX_SHORT;
+ class_name = zend_ast_markup_fq_name(ZEND_STRL(ZEND_MARKUP_ELEMENT_FQ));
+ args = zend_ast_list_add(args, name);
+ args = zend_ast_list_add(args, attrs);
+ args = zend_ast_list_add(args, children);
+ }
+
+ result = zend_ast_create(ZEND_AST_NEW, class_name, args);
+ result->lineno = lineno;
+ return result;
+}
+
+/* Build a markup element after checking the closing tag matches the opener
+ * (RFC §7). Both NULL means a `<>…>` fragment. `close` is consumed. */
+zend_ast *zend_ast_create_markup_checked(zend_ast *open, zend_ast *attrs, zend_ast *children, zend_ast *close)
+{
+ bool match;
+ if (open == NULL || close == NULL) {
+ match = (open == NULL && close == NULL);
+ } else {
+ match = zend_string_equals(Z_STR_P(zend_ast_get_zval(open)), Z_STR_P(zend_ast_get_zval(close)));
+ }
+ if (!match) {
+ zend_throw_exception_ex(zend_ce_compile_error, 0,
+ "Mismatched markup closing tag: expected %s>, found %s>",
+ open ? Z_STRVAL_P(zend_ast_get_zval(open)) : "",
+ close ? Z_STRVAL_P(zend_ast_get_zval(close)) : "");
+ goto fail;
+ }
+ if (close != NULL) {
+ zend_ast_destroy(close);
+ close = NULL;
+ }
+
+ return zend_ast_create_markup_element(open, attrs, children);
+
+fail:
+ if (open != NULL) {
+ zend_ast_destroy(open);
+ }
+ if (attrs != NULL) {
+ zend_ast_destroy(attrs);
+ }
+ zend_ast_destroy(children);
+ if (close != NULL) {
+ zend_ast_destroy(close);
+ }
+ return NULL;
+}
+
+/* Lower a dynamic tag `<$tag …>…$tag>` (RFC §4) into a call to
+ * \Html\render_dynamic($tag, attributes, children, namedSlots). The variable's
+ * runtime value decides what a static tag name decides at compile time, by the
+ * same classification rule (zend_markup_name_is_component): a component name
+ * dispatches through render_component's machinery (children become the
+ * anonymous-slot Fragment, hooks and decorators included), anything else
+ * constructs a literal \Html\Element. Only classification moves to runtime;
+ * the body is still partitioned into loose children vs blocks
+ * here, at compile time. `name` is the T_VARIABLE zval AST (the name without
+ * the "$"). */
+/* Build the \Html\render_dynamic(tag_expr, attributes, children, namedSlots)
+ * call both dynamic-tag forms lower into. `tag_expr` is an already-built
+ * expression AST producing the tag name; `lineno` is the opening-tag line
+ * (see zend_ast_create_markup_element for why it is stamped explicitly). */
+static zend_ast *zend_markup_dynamic_call(zend_ast *tag_expr, zend_ast *attrs, zend_ast *children, uint32_t lineno)
+{
+ if (attrs == NULL) {
+ attrs = zend_ast_create_list(0, ZEND_AST_ARRAY);
+ }
+ attrs->attr = ZEND_ARRAY_SYNTAX_SHORT;
+
+ zend_ast *anon, *named_slots;
+ zend_markup_partition_children(children, &anon, &named_slots);
+
+ zend_ast *fn = zend_ast_markup_fq_name(ZEND_STRL(ZEND_MARKUP_DYNAMIC_FQ));
+
+ zend_ast *args = zend_ast_create_list(0, ZEND_AST_ARG_LIST);
+ args = zend_ast_list_add(args, tag_expr);
+ args = zend_ast_list_add(args, attrs);
+ args = zend_ast_list_add(args, anon);
+ args = zend_ast_list_add(args, named_slots);
+
+ zend_ast *result = zend_ast_create(ZEND_AST_CALL, fn, args);
+ result->lineno = lineno;
+ return result;
+}
+
+zend_ast *zend_ast_create_markup_dynamic(zend_ast *name, zend_ast *attrs, zend_ast *children)
+{
+ uint32_t lineno = zend_ast_get_lineno(name);
+
+ zend_ast *var = zend_ast_create(ZEND_AST_VAR, name);
+ var->lineno = lineno;
+
+ return zend_markup_dynamic_call(var, attrs, children, lineno);
+}
+
+/* The brace form `<{expr} …>…>` / `<{expr} …/>`: the tag is an arbitrary
+ * expression, evaluated once. It closes anonymously (`>`, like a fragment)
+ * because a closing tag cannot restate the expression - re-evaluating it
+ * would run its side effects twice, and matching it textually is meaningless
+ * for computed values. */
+zend_ast *zend_ast_create_markup_dynamic_expr(zend_ast *expr, zend_ast *attrs, zend_ast *children)
+{
+ return zend_markup_dynamic_call(expr, attrs, children, zend_ast_get_lineno(expr));
+}
+
+/* Build a dynamic tag after checking the closing variable matches the opener:
+ * `<$a>…$b>` is a compile error, exactly as `
Welcome to PHP, where markup is a first-class expression.
+ >;
+}
+
+echo ; // prints the rendered HTML, dynamic values escaped
+```
+
+Despite appearances, this is not a new template language grafted onto PHP - the syntax is **pure compile-time sugar**. Every markup expression lowers, during compilation, to a plain `new` expression:
+
+```php
+// The new syntax...
+$html = ;
+
+// ...compiles to exactly this - same AST, same opcodes:
+$html = new \Html\Element('button', ['class' => 'btn'], ['Sign in']);
+```
+
+Nothing downstream of the parser changes, and markup obeys the expression semantics every PHP developer already knows.
+
+The two headline benefits over every existing option are **composition** (components, attributes, slots) and **escape-by-default safety** - neither of which PHP's existing markup facilities provide. And because markup is ordinary PHP code rather than a string dialect, static analysis reaches inside it: component props are named arguments checked against real signatures, so templates become type-checkable end-to-end.
+
+### Why this is worth doing
+
+Generating HTML is not a niche PHP task - it is arguably *the* PHP task, and essentially every major framework has independently built the same answer to it: an escape-by-default, composable template engine. Their adoption is enormous - on Packagist, `laravel/framework` (Blade) has over **540 million** installs and `twig/twig` over **460 million**, before counting Latte, Smarty, Plates, or hand-rolled templates that never appear in a dependency graph.
+
+That every engine converged on the same two features is strong evidence the need is real and the language does not meet it: developers reach for a third-party compiler, a separate dialect, and a build step to obtain what the runtime could provide natively. And escape-by-default is no mere convenience - cross-site scripting has sat in the OWASP Top 10 for its entire existence, and automatic HTML escaping is one of the disciplines that prevents it.
+
+## History
+
+This idea is not new - PHP is where it started. Around 2010, Facebook built **XHP**, a PHP extension that made XML/HTML fragments valid PHP expressions with automatic escaping - `Link` as a real value - and it survives today as a native feature of Hack. **JSX was born directly out of XHP**, and has since become *the* way most frontend developers write user interfaces. The pattern has proven itself at ecosystem scale; this RFC brings it home.
+
+The approach has surfaced in various forms many times over the years without a formal proposal; discussions about "automatic template escaping" resulted in arguments that "XHP really is the right solution for this problem," though that also raised the central objection - escaping is context-dependent (HTML vs URL vs JS vs CSS) - which this RFC answers by scoping escaping to HTML context only (see Escape-by-default).
+
+### Why core, and not an extension
+
+An obvious question - especially given the XHP precedent - is whether this could be proven out as a PECL extension first. It cannot, for one technical reason and one practical one.
+
+Technically, markup must begin at a bare `<` in operand position, and telling that apart from the comparison and shift operators requires the scanner change described under Parsing - state no extension can reach. The only route open to an extension is rewriting source text before compilation (the original XHP extension's approach), which shifts line numbers in errors and stack traces and leaves the raw file something `token_get_all()` - and therefore every tool built on it - cannot tokenize.
+
+Practically, a *syntax* lives or dies by its ecosystem. Code using an extension-only syntax cannot be reasonably published to Packagist - it is a parse error on any install without the extension - and no IDE, formatter, or static analyser updates its grammar for syntax that may not exist on a given machine. That chicken-and-egg is part of where XHP for PHP stalled, while the same feature thrived in Hack, where it is part of the language. Shipping in core is what makes markup part of PHP itself: parsers, IDEs, and analysis tools implement it once, and every developer's markup gets the same highlighting, formatting, and static analysis their ordinary code already enjoys.
+
+## Goals & Non-Goals
+
+The goal of this RFC is a native, ergonomic syntax for building HTML - the output format the overwhelming majority of PHP produces - with escaping and composition built in.
+
+This RFC is deliberately narrower than XHP: it targets **HTML specifically**, not general XML. That choice is visible throughout - escaping uses `htmlspecialchars`; void elements serialize as HTML5 (` `, not ` `); text and attribute handling follow HTML, not XML, semantics. There is no XML namespace support, no CDATA, no DTD, and no requirement that output be well-formed XML (`` *looks* like a namespaced XML element but is a compile-time construct, not a namespace). This keeps the surface small and the output aimed at the one thing PHP overwhelmingly produces: web pages. Emitting XML (RSS, SVG, SOAP) remains the job of the existing DOM / `XMLWriter` APIs and is out of scope.
+
+Semantic validation of attribute *values* is also not a goal - a `javascript:` URL in `href`/`src` escapes cleanly yet is not blocked: sometimes it is intentional, and the policy is the application's to set. (Latte nullifies such URLs and React warns; a framework can enforce the same here via a component decorator or userland wrapper.) Attribute and tag *names*, by contrast, are always validated - see Escape-by-default.
+
+The aim, in short: enough syntax to make HTML a first-class concern of PHP, with the extension points for frameworks to customize behaviour, and room to grow without breaking backward compatibility (see Future Scope).
+
+## Proposal
+
+### 1. A markup expression that evaluates to an object
+
+The core proposal is that a markup expression evaluates to an instance of a built-in type implementing `Html\Htmlable`. It is **not** a string - but casting it to one will return a string value.
+
+This is the JSX model (elements are objects, strings appear only after rendering) and is what makes composition and a single, central escaping step possible.
+
+#### Desugaring at a glance
+
+Every construct in this proposal lowers the same way - at compile time, to a `new` expression or a function call, behaviour PHP already supports well; no other part of the engine changes.
+
+```php
+// Attributes: literal, ={expr}, bare boolean, {...spread} (see Attributes)
+{$label}
+// → new \Html\Element('a', ['href' => $url, 'class' => 'btn'], [$label])
+
+// Fragments group children with no wrapper element (see Syntax structure)
+<>
{$title}
>
+// → new \Html\Fragment([
+// new \Html\Element('h1', [], [$title]),
+// new \Html\Element('input', [...$attrs, 'required' => true], []),
+// ])
+
+// Components dispatch through Html\render_component() (see Tags)
+
+// → \Html\render_component(Card::class, ['title' => 'Hi'], null, [], 'Card')
+
+// Component body → anonymous slot; blocks → named slots (see Children & slots)
+
+
*/]), // anonymous slot
+// ['header' => new \Html\Fragment([/*
...
*/])], // named slots
+// 'Layout', // function-name candidate
+// )
+
+// Dynamic tags: the value classifies at runtime (see Tags)
+<$tag class="box">{$content}$tag>
+// → \Html\render_dynamic($tag, ['class' => 'box'], [$content], [])
+```
+
+Two properties fall directly out of this lowering. Markup is **zero-cost sugar** - writing the objects by hand produces the same AST and opcodes. And markup obeys **ordinary expression semantics** - attributes and children are argument expressions, evaluated eagerly, scoped and typed like any other PHP code.
+
+### 2. Escape-by-default
+
+This is a *templating* feature, and like every major PHP template engine (Blade, Twig, Latte) it **escapes by default**:
+
+* Interpolations `{$expr}` and **attribute values** are escaped with `htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401)` - like `htmlspecialchars()` itself, the charset comes from ini `default_charset` (UTF-8 by default), so non-UTF-8 applications keep working.
+* Any value implementing `Html\Htmlable` (including elements and fragments) passes through **un-escaped** - it is already assumed to be safe HTML.
+* The explicit, greppable opt-out for trusted raw strings is `Html\raw($trustedString)`, which returns an `Html\Htmlable`. `Html\escape($string)` is also provided.
+
+Escaping is **HTML-context only**. URL/JS/CSS-context escaping is explicitly **out of scope** and remains the author's responsibility. Full context-aware escaping is noted as Future Scope.
+
+A single escaping context is sound here in a way it is not for string-template engines, because there is no context to *detect*:
+
+* The grammar admits dynamic data in few positions - child content and attribute values - each escaped by one fixed rule (attribute values are always serialized double-quoted; no unquoted output form exists).
+* Dynamically-supplied *names* (spread keys, `new Element($tag)`) are **validated** and can throw a runtime exception, not escaped - an invalid name is a `ValueError`, so a name can never break out of the markup.
+* `` breakout is inexpressible.
+
+### 3. Tags: HTML elements vs components
+
+Dispatch is purely **syntactic** (the compiler cannot know runtime types):
+
+| Tag form | Meaning |
+|---|---|
+| `
`, `` (lowercase / hyphenated) | literal HTML element → `Html\Element` |
+| `` (bare, capitalized) | a **component** - resolved as a name honoring `use` / `use function` / namespace |
+| ``, `<\App\Card>` (namespace-qualified) | a **component** - a namespace-qualified or fully-qualified name |
+| `` | a **component** - a static method call on a class |
+| `<$tag>`, `<{expr}>` (a variable / any expression) | a **dynamic tag** - the runtime string value is classified by the same rule, at runtime |
+
+Any tag whose name is capitalized, contains a namespace separator (`\`), or names a static method (`::`) is a component; everything else is a literal HTML element.
+
+A **component is anything that produces `Html\Htmlable`.** There are three forms:
+
+```php
+// Function
+function Time(DateTimeInterface $datetime): Html\Htmlable { /* ... */ }
+// → Time(datetime: $datetime)
+
+// Static method - multiple components can live together on one class
+class Author {
+ public static function byline(string $name): Html\Htmlable { /* ... */ }
+ public static function avatar(string $src): Html\Htmlable { /* ... */ }
+}
+// → Author::byline(name: 'Rasmus')
+
+// Class implementing Html\Htmlable - the instance IS the renderable
+class Card implements Html\Htmlable {
+ public function __construct(public string $title) {}
+ public function toHtml(): Html\Htmlable {
+ return
{$this->title}
;
+ }
+}
+// → new Card(title: 'Hi')
+```
+
+`toHtml()` returns markup, not a string, so it composes under `strict_types` with no cast, and callers can still reach the produced `Html\Element` tree. It may also return another `Htmlable` - e.g. delegate to another component instance.
+
+#### Bare-tag and qualified-tag resolution
+
+A component tag names a class or a function. PHP resolves class names and function names through **separate** rules and `use` tables, and a bare `` tag gives no syntactic signal which is meant, so the compiler resolves the name **both ways** and lets the runtime pick:
+
+* as a **class** name - honoring `use` and the current namespace (as `Foo::class` does);
+* as a **function** name - honoring `use function` and the current namespace.
+
+The two candidates are then dispatched at runtime in this order:
+
+1. the class candidate names a class implementing `Html\Htmlable` → instantiate it;
+2. else the function candidate names a userland function → call it (its result must be `Html\Htmlable`);
+3. else → a clear error.
+
+The consequence is that components follow ordinary PHP scoping exactly - a class component is imported with `use`, a function component with `use function`, and using the wrong keyword fails just as it would in a normal `new`/call:
+
+```php
+namespace Views;
+
+use App\Card; // a class component
+use function App\Greeting; // a function component
+
+ // → new App\Card(title: 'Hi')
+ // → App\greeting()
+```
+
+Tags may also be written **namespace-qualified** or **fully-qualified** directly; any tag containing `\` is a component regardless of the first letter's case:
+
+```php
+ // namespace-relative
+<\App\Card title="Hi"/> // fully-qualified
+```
+
+#### Dynamic tags - `<$tag>` and `<{expr}>`
+
+The tag position also accepts a dynamic value, for the common CMS/builder case where the element or component is data ("render this list of blocks"). It follows the rule PHP developers already know from double-quoted string interpolation: a simple variable may appear bare, and braces admit any expression:
+
+```php
+$tag = $isImportant ? 'strong' : 'span';
+echo <$tag class="note">{$text}$tag>;
+
+echo <{$block->type} {...$block->attributes} />;
+echo <{$isImportant ? 'strong' : 'span'}>{$text}>;
+```
+
+This is consistent with the rest of the grammar, where braces already mark every dynamic value.
+
+The value is classified **at runtime by exactly the compile-time rule**: a string with an uppercase first character, a `\`, or a `::` dispatches as a component; anything else constructs a literal `Html\Element`. So `$tag = 'div'` behaves like `
` and `$component = Card::class` behaves like ``, and only classification moves to runtime. Two differences follow from the name being data rather than source: it is not resolved against `use` imports (there is no compile-time name to resolve - pass a fully-qualified name, which `::class` already produces), and element names are validated when the tree is serialized rather than when it is parsed (the existing `Html\Element` rule; an invalid name throws, it can never break out of the markup).
+
+Closing follows the form:
+- A variable tag closes by repeating the same variable such as `<$a>...$a>`
+ - `<$a>...$b>` is a compile error, exactly as `
...` is
+- An expression tag with a brace closes **anonymously** with `>`
+ - A closing tag cannot restate the expression - re-evaluating it would run its side effects twice, and matching it textually is meaningless for a computed value; the expression is evaluated exactly once.
+- A self-closing `<$tag/>` / `<{expr}/>` needs no closer at all.
+
+#### Userland integration: dispatch hooks
+
+By default the engine constructs a class component with `new` and calls a function or static-method component directly. In userland, particularly within frameworks, they will often want to intervene - to resolve components through a dependency-injection container (autowiring the constructor/parameter dependencies the props do not supply), or to apply a cross-cutting transform to every component's output. Three request-scoped hook lists make this possible without touching any component's code. Each behaves like `spl_autoload_register` - handlers are kept in registration order, with a matching `unregister_*` that reports whether a handler matched:
+
+| Hook | Fires for | Signature | Purpose |
+|---|---|---|---|
+| `Html\register_component_factory` | class components | `(string $class, array $args): ?object` | replace `new` (e.g. container construction) |
+| `Html\register_component_invoker` | function & static-method components | `(callable $component, array $args): ?Html\Htmlable` | replace the call (e.g. parameter autowiring) |
+| `Html\register_component_decorator` | **every** component kind | `(Html\Htmlable $rendered, string $component): Html\Htmlable` | transform / wrap / log / cache the output |
+
+The engine always does the part only it can: resolve the name, confirm the target, and route props + `#[Html\Slot]` content into `$args`. A **factory** or **invoker** only changes *how the value is produced* - it returns the value, or `null` to defer to the next handler (and finally to the engine's own `new`/call); a factory's result must be an instance of the requested class, an invoker's must be `Html\Htmlable`.
+
+Construction and invocation are separate hooks because a container needs different information to autowire each (the class name vs. the callable). A **decorator** is the cross-cutting seam: it runs on the `Html\Htmlable` every component produces, composing in registration order. (Plain HTML elements such as `
` are not components; no hook fires for them.)
+
+Every `register_*`/`unregister_*` function also takes an optional second argument, `?string $component = null`, that scopes the hook to a single component: the hook only fires when the resolved name it would receive - the class FQCN, the function name, or `"Class::method"` - matches (case-insensitively, with or without a leading backslash). Scoped and unscoped hooks share one chain in registration order, and the dispatch skips out-of-scope hooks inside the engine, before any userland call, so a hook registered for one component costs the others nothing. Unregistering matches on both the callable and the scope it was registered with.
+
+Registration is scoped to the current runtime, so a framework registers hooks during bootstrap; when nothing is registered the hooks add no cost - the default `new`/call path is unchanged.
+
+```php
+$container = ...;
+
+// A framework's bootstrap: wire components to the service container...
+Html\register_component_factory(
+ fn(string $class, array $args) => $container->make($class, $args)
+);
+Html\register_component_invoker(
+ fn(callable $fn, array $args) => $container->call($fn, $args)
+);
+
+// ...and a cross-cutting decorator (profiling, caching, wrapping, ...).
+Html\register_component_decorator(function (Html\Htmlable $rendered, string $component) {
+ return $rendered;
+});
+```
+
+A component can then simply declare its dependencies; the container supplies them while markup attributes still fill the remaining parameters as named arguments:
+
+```php
+final class UserCard implements Html\Htmlable {
+ public function __construct(private Clock $clock, public string $name) {}
+ public function toHtml(): Html\Htmlable { /* ...uses $this->clock... */ }
+}
+
+ // the container injects $clock; the prop fills $name
+```
+
+### 4. Attributes
+
+```php
+{$label}
+```
+
+| Form | Meaning |
+|---|---|
+| `class="btn"` | literal string |
+| `href={$expr}` | PHP expression (same `{}` as interpolation) |
+| `disabled` (bare) | boolean `true` |
+| `{...$attrs}` | spread (array → attributes; named-argument unpacking for components) |
+
+* Attribute names are **native HTML names** (`class`, `for`, `data-x`) - *not* `className`. PHP accepts reserved words as parameter names and named-argument labels, so no `className`/`htmlFor` aliasing is needed like JSX.
+* On **HTML elements**, attributes become a string-keyed map.
+* On **components**, attributes become **named arguments**. Unknown attributes are a `TypeError` (`Unknown named parameter`) unless the component declares a `...$attrs` variadic, which collects them as a string-keyed array - exactly PHP's existing named-argument + variadic semantics, no new machinery. Hyphenated names (`data-x`, `aria-y`) are not legal named-argument *labels* in a normal call, but as markup attributes they pass through the same mechanism and are collected by the variadic.
+
+#### Attribute value coercion
+
+| Value | Result |
+|---|---|
+| `null` / `false` | attribute **omitted** |
+| `true` | boolean attribute (bare name) |
+| `string` / `int` / `float` / `Stringable` | `="escaped value"` |
+| `array` / other | `TypeError` |
+
+### 5. Children, slots, and named slots
+
+#### HTML elements
+
+Children are an ordered list of child nodes (text, interpolations, sub-elements).
+
+#### Components - slots via `#[Html\Slot]`
+
+The body of a component is routed to a constructor/function/method parameter marked with the `#[Html\Slot]` attribute.
+
+```php
+function Time(
+ DateTimeInterface $datetime,
+ #[Html\Slot] ?Html\Htmlable $slot = null,
+): Html\Htmlable {
+ return ;
+}
+
+// → Time(datetime: $d)
+// → Time(datetime: $d, slot: )
+```
+
+The slot value is always a single `Html\Fragment` (an `Html\Htmlable` whose children are each normalized to `Html\Htmlable`); `{$slot}` renders the whole body, correctly escaped. The fragment's children are introspectable as an opt-in (`$slot->children`).
+
+#### Named slots - ``
+
+`#[Html\Slot]` takes an optional name. Bare `#[Html\Slot]` is the **default/anonymous** slot (receiving loose body content); `#[Html\Slot('footer')]` is a **named** slot, filled by a `...` block.
+
+```php
+function Layout(
+ #[Html\Slot] Html\Htmlable $body,
+ #[Html\Slot('header')] ?Html\Htmlable $header = null,
+ #[Html\Slot('footer')] ?Html\Htmlable $footer = null,
+): Html\Htmlable { /* ... */ }
+
+
+
// optional (null → "")
+```
+
+Conditionals use a ternary, not the JSX `{condition && }` idiom: in PHP, `&&` evaluates to a boolean, so `{$cond && }` would render `"1"`, not the element.
+
+### 6. Syntax structure
+
+* **Closing tags must match** their opener, including `` and the dynamic `$tag>`. Mismatches are a compile error (catches the most common markup typo early).
+* **Childless elements**: `` and `` are equivalent - existing HTML can be pasted into markup unchanged. The parser needs **no hardcoded HTML void-element list**; the serializer still *emits* ` ` (no closing tag, no slash) for known void elements, so output is clean HTML5 whichever source form was used. Giving a void element children (possible when constructing `Html\Element` by hand) is an error rather than silent data loss.
+* **Fragments** `<>...>` produce an `Html\Fragment` with no wrapper element.
+* **Whitespace** follows the JSX/Babel algorithm: split each text run into lines, trim leading whitespace on every line but the first and trailing whitespace on every line but the last, drop blank lines, and join the surviving lines with a single space. The net effect is that indentation/newlines between block elements vanish while a meaningful single space between inline content is preserved.
+* **Character references**: markup text and literal attribute values decode HTML character references at compile time - the full WHATWG named set (semicolon-terminated forms only) plus numeric `—` / `—`. The named list is *frozen by the HTML standard*, so it is baked into the scanner as a generated table. Decoding is lenient like HTML and JSX: a bare `&`, an unknown name, or an invalid numeric form stays literal - `
fish & chips
` needs no escaping. The decoded value is an ordinary string escaped at render time, so `&` round-trips and `<script>` can never smuggle real markup structure. Text decodes *after* whitespace normalization; comment contents and interpolated PHP strings are never decoded, and `token_get_all()` still sees the raw source.
+* **Literal braces**: `{` begins interpolation, so a literal `{` in text is written as in HTML or JSX - a character reference (`{` / `{`) or an interpolated string (`{'{'}`). A lone `}` is already literal. No additional escape syntax (eg. Blade-style `@{{`) is introduced. A block of literal JSON/JS inside `;
+
+echo ;
+
+echo
{'{'}example{'}'}
;
+```
+
+### Components are testable values
+
+A component call is an ordinary call and its result an ordinary value object, so a component is unit-tested directly: call it, assert against the tree - no rendering, no string matching, no DOM parsing:
+
+```php
+public function testSaleBadgeShownWhenOnSale(): void
+{
+ $el = ProductBadge(product: $this->saleProduct());
+
+ $this->assertInstanceOf(Html\Element::class, $el);
+ $this->assertSame('span', $el->tag);
+ $this->assertSame('badge sale', $el->attributes['class']);
+}
+```
+
+Views decompose the way any other code does: a class of small component methods, each testable in isolation, composed by a top-level method returning the final page.
+
+### Framework components - a Livewire-style clock
+
+In frameworks whose components are PHP classes that render themselves - Livewire is the clearest example - the class and its markup are forced apart today: the component holds the state and behaviour, while the HTML lives in a separate Blade file the class points to by name.
+
+With markup expressions the same component could collapse into one self-contained class - `render()` returns the view instead of naming a file that contains it:
+
+```php
+class Clock extends Component
+{
+ public string $timezone = 'UTC';
+
+ public function render(): Html\Htmlable
+ {
+ return
+ {now($this->timezone)->format('H:i:s')}
+
;
+ }
+}
+```
+
+## Parsing the `<` ambiguity
+
+`<` is heavily overloaded in PHP (`<`, `<=`, `<=>`, `<<`, `<>`, `<<<`), so this was the main design risk. It is resolved by the same technique JavaScript uses to tell regex from division: the scanner tracks whether it is in **operand position** (a value is expected) or **operator position** (a value just ended). Markup begins only when `<` is in operand position *and* is immediately followed by `[A-Za-z>]`, by `\` then a letter (a fully-qualified component tag such as `<\App\Card/>`), by `$` then a variable name, or by `{` (the dynamic tags `<$tag>` / `<{expr}>`):
+
+* In `$a < $b` (or the no-space `$a <$b`), `<` follows an operand → comparison, unchanged.
+* In `$a < \Foo::BAR`, `<` is in operator position → comparison, unchanged.
+* In `return
`, `<` is in operand position followed by a letter → markup.
+
+Because `<` followed by a letter, `$`, `{`, or `>` in operand position is *always a syntax error* in PHP today, reclaiming it does not change the meaning of any valid program. Every operand-ending context (`static`, `exit`, bare `yield`, postfix `++`/`--`, `::class`, closing `)`/`]`/`}`, heredoc terminators, string closers) is explicitly kept as a comparison, which compare the received value today and still do.
+
+This is **implemented and verified**: the full Zend and PHP language test suites pass with **zero regressions** (6000+ tests), and `token_get_all()` continues to tokenize ordinary code identically (the operand/operator tracking is one extra store per token, with no measurable overhead).
+
+## Backward Incompatible Changes
+
+1. **`<>` operator** - `<>` is the legacy alias of `!=`. It is **preserved in infix position** (`$a <> $b` is still not-equal). `<>` is only reinterpreted as a fragment opener in **operand position**, where it is currently a syntax error - so no valid program needs changes.
+2. **`<` in operand position followed by a letter** (or by `\`+letter for a fully-qualified component tag, or by `$`+name / `{` for a dynamic tag) - currently a syntax error in such positions; reclaimed for markup, so no valid program needs changes.
+3. **New `Html\` namespace** - introduces `Html\Htmlable`, `Html\Element`, `Html\Fragment`, `Html\Raw`, `Html\Slot`, `Html\raw()`, `Html\escape()`. Top-level `Html\` follows the established precedent for bundled extensions (`Random\`, `Dom\`, `Uri\`), and real-world top-level `Html\` packages are rare. No existing keywords are reserved and no new global reserved words are introduced (the syntax uses bare `<` and a `slot:` form valid only inside markup).
+
+### Impact on tooling
+
+The scanner emits new `T_MARKUP_*` tokens (exposed as constants and through `token_get_all()`/`PhpToken`); ordinary code continues to tokenize identically. Tools that parse PHP source (nikic/php-parser, PHPStan, Psalm, php-cs-fixer, IDEs) will need grammar updates to understand markup expressions, as with any new syntax.
+
+A working syntax-highlighting/IDE extension for VS Code was built alongside the reference implementation to validate that the syntax is toolable.
+
+Because markup lowers to ordinary AST at compile time, everything downstream of the parser is unaffected: opcache caches and restores markup-containing files normally (verified, including the file cache), and reflection, serialization, `var_export()`, `clone` and `json_encode()` on the value objects behave as for any other class.
+
+A related question is what this means for **template engines** (Blade, Twig, Latte). This RFC does not replace them, and does not aim to: a full engine is much more than syntax plus escaping - layout inheritance, a designer-facing dialect that is deliberately *not* PHP, sandboxed rendering of untrusted templates, context-aware escaping, fragment caching, and years of ecosystem conventions. Nothing here removes any of that; markup is opt-in syntax, and code that never uses it is untouched.
+
+What this RFC offers the engines is a first-class substrate to build on. Today each engine maintains its own parser, its own escaping, and its own component model, because the language provides none. With markup native, an engine can adopt the shared foundation where it fits: `Html\Htmlable` is a common currency (a Blade view and a markup expression can accept and return the same interface and compose within one page); the dispatch hooks exist precisely so a framework can route component tags through its existing container and resolution logic; and an engine's component layer could compile *to* markup expressions, inheriting escape-by-default and end-to-end static analysis for free. The likely long-term shape is convergence rather than replacement: engines keep their higher-level features and progressively delegate parsing, escaping, and the value model to the language - much as database layers did not disappear when PDO shipped, but re-based onto it.
+
+## Future Scope
+
+Some natural extensions are deliberately left out of this RFC to keep its scope tight and expandable in the future; none is required for the feature to be useful:
+
+* **Context-aware escaping** (URL/JS/CSS contexts), as in Go's `html/template` or Latte's escaping, - so that e.g. a `javascript:` URL in `href` is caught. This RFC escapes HTML context only; the value-tree model is designed so this can be layered on later without a syntax change. Some examples include:
+ * A JavaScript escaping context inside `';
+echo (new Element('span', [], [$evil]))->__toString(), "\n";
+?>
+--EXPECT--
+
Tom & Jerry <3 "quotes" 'apostrophe'
+
+<script>alert(1)</script>
diff --git a/ext/html/tests/fragment_void_raw.phpt b/ext/html/tests/fragment_void_raw.phpt
new file mode 100644
index 000000000000..0da6b5d359f3
--- /dev/null
+++ b/ext/html/tests/fragment_void_raw.phpt
@@ -0,0 +1,40 @@
+--TEST--
+Html\Fragment, void-element serialization, and raw()/escape() helpers
+--EXTENSIONS--
+html
+--FILE--
+__toString(), "\n";
+
+// Void elements emit clean HTML5 (no slash, no closing tag).
+echo (new Element('br'))->__toString(), "\n";
+echo (new Element('img', ['src' => '/a.png', 'alt' => 'A & B']))->__toString(), "\n";
+
+// raw(): trusted passthrough, not escaped.
+echo raw('x')->__toString(), "\n";
+
+// escape(): escaped once, then safe to embed without double-escaping.
+$safe = escape('x');
+echo $safe->__toString(), "\n";
+echo (new Element('p', [], [$safe]))->__toString(), "\n"; // not escaped again
+
+var_dump(raw('y') instanceof Html\Htmlable);
+?>
+--EXPECT--
+
Title
Body
+
+
+x
+<b>x</b>
+
<b>x</b>
+bool(true)
diff --git a/ext/html/tests/htmlable_default_tostring.phpt b/ext/html/tests/htmlable_default_tostring.phpt
new file mode 100644
index 000000000000..f9963cc6c4d0
--- /dev/null
+++ b/ext/html/tests/htmlable_default_tostring.phpt
@@ -0,0 +1,71 @@
+--TEST--
+Html\Htmlable: the default __toString() is injected at class-link time and yields to a declared one
+--EXTENSIONS--
+html
+--FILE--
+markup; }
+ public function __toString(): string { return 'CUSTOM'; }
+}
+echo new Custom(), "\n";
+echo {new Custom()}, "\n";
+
+// A __toString flattened in from a trait counts as declared.
+trait Loud {
+ public function __toString(): string { return 'LOUD'; }
+}
+class UsesTrait implements Html\Htmlable {
+ use Loud;
+ public function toHtml(): Html\Htmlable { return Html\raw('quiet'); }
+}
+echo new UsesTrait(), " / ", {new UsesTrait()}, "\n";
+
+// A __toString inherited from a parent class also wins.
+class StringyBase {
+ public function __toString(): string { return 'BASE'; }
+}
+class ChildOfStringy extends StringyBase implements Html\Htmlable {
+ public function toHtml(): Html\Htmlable { return Html\raw('child'); }
+}
+echo new ChildOfStringy(), " / ", {new ChildOfStringy()}, "\n";
+
+// An abstract class implementing Htmlable: concrete children inherit the
+// injected default.
+abstract class Widget implements Html\Htmlable {}
+class Button extends Widget {
+ public function toHtml(): Html\Htmlable { return ; }
+}
+echo new Button(), "\n";
+
+// An interface extending Htmlable stays abstract; implementers get the default.
+interface Panel extends Html\Htmlable {}
+class SidePanel implements Panel {
+ public function toHtml(): Html\Htmlable { return ; }
+}
+echo new SidePanel(), "\n";
+
+// The injected method is a real internal method: visible to reflection, with
+// the declared string return type, owned by the implementing class.
+$rm = new ReflectionMethod(SidePanel::class, '__toString');
+var_dump($rm->isInternal(), (string) $rm->getReturnType(), $rm->getDeclaringClass()->getName());
+var_dump(method_exists(new Button(), '__toString'));
+var_dump(is_callable([new Button(), '__toString']));
+echo (new Button())->__toString(), "\n";
+?>
+--EXPECT--
+CUSTOM
+markup
+LOUD / quiet
+BASE / child
+
+
+bool(true)
+string(6) "string"
+string(9) "SidePanel"
+bool(true)
+bool(true)
+
diff --git a/ext/html/tests/htmlable_missing_tohtml.phpt b/ext/html/tests/htmlable_missing_tohtml.phpt
new file mode 100644
index 000000000000..efb3c74608a3
--- /dev/null
+++ b/ext/html/tests/htmlable_missing_tohtml.phpt
@@ -0,0 +1,12 @@
+--TEST--
+Html\Htmlable: a class implementing neither toHtml() nor __toString() only owes toHtml()
+--EXTENSIONS--
+html
+--FILE--
+
+--EXPECTF--
+Fatal error: Class Nope contains 1 abstract method and must therefore be declared abstract or implement the remaining method (Html\Htmlable::toHtml) in %s on line %d
diff --git a/ext/html/tests/htmlable_tohtml.phpt b/ext/html/tests/htmlable_tohtml.phpt
new file mode 100644
index 000000000000..8f045a8417b9
--- /dev/null
+++ b/ext/html/tests/htmlable_tohtml.phpt
@@ -0,0 +1,64 @@
+--TEST--
+Html\Htmlable::toHtml() is the render contract; __toString comes for free (RFC §3)
+--EXTENSIONS--
+html
+--FILE--
+{$this->title}
;
+ }
+}
+
+$card = new Card('Hi & bye');
+var_dump($card->toHtml() instanceof Html\Element);
+var_dump($card->toHtml()->tag);
+
+// The injected default __toString renders via toHtml(), so echo, (string),
+// and interpolation all work without the class declaring __toString.
+echo $card, "\n";
+echo (string) $card, "\n";
+echo "wrapped: {$card}\n";
+var_dump($card instanceof Stringable);
+
+// In markup child position the component renders through toHtml() natively.
+echo {$card}, "\n";
+
+// And as a component tag.
+echo , "\n";
+
+// toHtml() may return another Htmlable (e.g. delegate to another component);
+// resolution recurses until it reaches a node class.
+class Fancy implements Html\Htmlable {
+ public function toHtml(): Html\Htmlable {
+ return new Card('delegated');
+ }
+}
+echo new Fancy(), "\n";
+
+// The node classes are the base cases: their toHtml() returns themselves.
+$el = x;
+var_dump($el->toHtml() === $el);
+$frag = <>y>;
+var_dump($frag->toHtml() === $frag);
+$raw = Html\raw('z');
+var_dump($raw->toHtml() === $raw);
+?>
+--EXPECT--
+bool(true)
+string(3) "div"
+
Hi & bye
+
Hi & bye
+wrapped:
Hi & bye
+bool(true)
+
Hi & bye
+
Tagged
+
delegated
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/html/tests/htmlable_tohtml_errors.phpt b/ext/html/tests/htmlable_tohtml_errors.phpt
new file mode 100644
index 000000000000..803a3fb6aa8c
--- /dev/null
+++ b/ext/html/tests/htmlable_tohtml_errors.phpt
@@ -0,0 +1,44 @@
+--TEST--
+Html\Htmlable::toHtml(): resolution cycles and throwing implementations are contained
+--EXTENSIONS--
+html
+--FILE--
+getMessage(), "\n"; }
+try { echo
{new Selfish()}
; }
+catch (Error $e) { echo get_class($e), ": ", $e->getMessage(), "\n"; }
+
+// A two-class cycle is caught by the same bound.
+class Ping implements Html\Htmlable {
+ public function toHtml(): Html\Htmlable { return new Pong(); }
+}
+class Pong implements Html\Htmlable {
+ public function toHtml(): Html\Htmlable { return new Ping(); }
+}
+try { echo new Ping(); }
+catch (Error $e) { echo get_class($e), ": ", $e->getMessage(), "\n"; }
+
+// An exception thrown inside toHtml() propagates cleanly through the injected
+// __toString (both directly and from child position).
+class Boom implements Html\Htmlable {
+ public function toHtml(): Html\Htmlable { throw new RuntimeException('boom'); }
+}
+try { echo new Boom(); }
+catch (RuntimeException $e) { echo get_class($e), ": ", $e->getMessage(), "\n"; }
+try { echo
{new Boom()}
; }
+catch (RuntimeException $e) { echo get_class($e), ": ", $e->getMessage(), "\n"; }
+
+echo "clean\n";
+?>
+--EXPECT--
+Error: Maximum toHtml() resolution depth of 64 exceeded (Selfish::toHtml() never produced an Html\Element, Html\Fragment, or Html\Raw)
+Error: Maximum toHtml() resolution depth of 64 exceeded (Selfish::toHtml() never produced an Html\Element, Html\Fragment, or Html\Raw)
+Error: Maximum toHtml() resolution depth of 64 exceeded (Ping::toHtml() never produced an Html\Element, Html\Fragment, or Html\Raw)
+RuntimeException: boom
+RuntimeException: boom
+clean
diff --git a/ext/html/tests/htmlable_tohtml_opcache.phpt b/ext/html/tests/htmlable_tohtml_opcache.phpt
new file mode 100644
index 000000000000..decb32061c37
--- /dev/null
+++ b/ext/html/tests/htmlable_tohtml_opcache.phpt
@@ -0,0 +1,34 @@
+--TEST--
+Html\Htmlable: the injected default __toString() survives opcache (arena-allocated internal method)
+--EXTENSIONS--
+html
+--SKIPIF--
+
+--INI--
+opcache.enable=1
+opcache.enable_cli=1
+--FILE--
+{$this->title}
;
+ }
+}
+
+// Inheritance pushes the linked class (with its injected internal method)
+// through opcache's inheritance cache / persistence paths.
+class FancyCard extends Card {}
+
+echo new Card('a & b'), "\n";
+echo new FancyCard('c'), "\n";
+echo {new Card('nested')}, "\n";
+var_dump((new ReflectionMethod(FancyCard::class, '__toString'))->isInternal());
+?>
+--EXPECT--
+
a & b
+
c
+
nested
+bool(true)
diff --git a/ext/html/tests/security_names.phpt b/ext/html/tests/security_names.phpt
new file mode 100644
index 000000000000..8af0c52f2e86
--- /dev/null
+++ b/ext/html/tests/security_names.phpt
@@ -0,0 +1,34 @@
+--TEST--
+Html: dynamic tag/attribute names are validated so they can't break out (security)
+--EXTENSIONS--
+html
+--FILE--
+__toString(); }
+ catch (\Throwable $e) { return get_class($e) . ': ' . $e->getMessage(); }
+}
+
+// Attribute name from an attacker-controlled spread key - must be rejected.
+echo err(fn() => new E('a', ['x" onmouseover="alert(1)' => 'y'], ['hi'])), "\n";
+
+// Tag name from a dynamic source - must be rejected.
+echo err(fn() => new E('a>', [], [])), "\n";
+
+// Empty names rejected.
+echo err(fn() => new E('', [], [])), "\n";
+echo err(fn() => new E('div', ['' => 'x'], [])), "\n";
+
+// Legitimate dynamic names still work (custom elements, data-/aria-, namespaces).
+echo (new E('my-widget', ['data-x' => '1', 'aria-label' => 'ok', 'xml:lang' => 'en'], ['x']))->__toString(), "\n";
+echo ( 5]}>ok), "\n";
+?>
+--EXPECTF--
+ValueError: Invalid attribute name "x" onmouseover="alert(1)"
+ValueError: Invalid tag name "a>"
+ValueError: Invalid tag name ""
+ValueError: Invalid attribute name ""
+x
+ok
diff --git a/ext/html/tests/slot_attribute.phpt b/ext/html/tests/slot_attribute.phpt
new file mode 100644
index 000000000000..6cf871d02a97
--- /dev/null
+++ b/ext/html/tests/slot_attribute.phpt
@@ -0,0 +1,31 @@
+--TEST--
+Html\Slot attribute is reflectable with optional name (RFC §6)
+--EXTENSIONS--
+html
+--FILE--
+getParameters() as $param) {
+ foreach ($param->getAttributes(Slot::class) as $attr) {
+ $slot = $attr->newInstance();
+ printf("%s -> name=%s\n", $param->getName(), var_export($slot->name, true));
+ }
+}
+
+// Slot only targets parameters.
+$attr = (new ReflectionClass(Slot::class))->getAttributes(Attribute::class)[0]->newInstance();
+var_dump($attr->flags === Attribute::TARGET_PARAMETER);
+?>
+--EXPECT--
+body -> name=NULL
+footer -> name='footer'
+bool(true)
diff --git a/ext/html/tests/syntax_attr_prefix.phpt b/ext/html/tests/syntax_attr_prefix.phpt
new file mode 100644
index 000000000000..55deaa7ceb4e
--- /dev/null
+++ b/ext/html/tests/syntax_attr_prefix.phpt
@@ -0,0 +1,68 @@
+--TEST--
+Markup syntax: ":" / "@" as an optional leading character of attribute names (RFC §4)
+--EXTENSIONS--
+html
+--FILE--
+t
, "\n";
+echo , "\n";
+
+// The prefix composes with the rest of the name charset (Alpine long forms,
+// modifiers) - only the *leading* character is new.
+echo , "\n";
+
+// Prefixed keys also pass the runtime name validation: spread and new Element.
+$attrs = ['@click' => 'save()', ':class' => 'cls'];
+echo s, "\n";
+echo new Html\Element('i', ['@x' => '1'], []), "\n";
+
+// On a component the names were never valid named-argument labels, so - like
+// data-* - they are collected by a variadic.
+function Btn(string $label, ...$attrs): Html\Htmlable {
+ return ;
+}
+echo , "\n";
+
+// Attribute values are still escaped like any other.
+echo
x
, "\n";
+
+// Attribute position only: a prefixed name can never begin a tag. At top
+// level "<:" / "<@" do not start markup at all; nested in markup content a
+// stray "<" gives the dedicated error.
+try {
+ eval('$x =
;');
+} catch (ParseError $e) {
+ echo $e->getMessage(), "\n";
+}
+
+// The prefix must be followed by a letter, and closing tags take no attributes.
+try {
+ eval('$x =
+Unescaped "<" in markup text; write {'<'} for a literal less-than sign
+Unescaped "<" in markup text; write {'<'} for a literal less-than sign
+syntax error, unexpected T_ERROR "@", expecting markup tag name or markup tag end or markup self-close or markup interpolation start
+syntax error, unexpected T_ERROR "@", expecting markup tag end
diff --git a/ext/html/tests/syntax_attributes.phpt b/ext/html/tests/syntax_attributes.phpt
new file mode 100644
index 000000000000..f1d6ad4714ac
--- /dev/null
+++ b/ext/html/tests/syntax_attributes.phpt
@@ -0,0 +1,25 @@
+--TEST--
+Markup syntax: attributes (literal, {expr}, bare boolean) with coercion (RFC §5)
+--EXTENSIONS--
+html
+--FILE--
+link)->__toString(), "\n";
+
+// attribute value is escaped
+echo (
x
)->__toString(), "\n";
+
+// self-closing element with attributes
+echo ()->__toString(), "\n";
+?>
+--EXPECT--
+link
+
x
+
diff --git a/ext/html/tests/syntax_basic.phpt b/ext/html/tests/syntax_basic.phpt
new file mode 100644
index 000000000000..b10a8e36e213
--- /dev/null
+++ b/ext/html/tests/syntax_basic.phpt
@@ -0,0 +1,36 @@
+--TEST--
+Markup syntax: elements, interpolation, nesting, self-close, fragments (RFC §7)
+--EXTENSIONS--
+html
+--FILE--
+";
+
+// Element with interpolation and a nested element.
+$el =
Hello {$name} world
;
+var_dump($el instanceof Html\Element);
+echo $el->__toString(), "\n";
+
+// Self-closing element -> clean void-element output.
+echo ( )->__toString(), "\n";
+echo ()->__toString(), "\n";
+
+// Fragment (no wrapper).
+$frag = <>
Title
Body {$name}
>;
+var_dump($frag instanceof Html\Fragment);
+echo $frag->__toString(), "\n";
+
+// Interpolation can hold any expression; arrays flatten, null renders "".
+$items = ['x', 'y'];
+echo (
diff --git a/ext/html/tests/syntax_bc.phpt b/ext/html/tests/syntax_bc.phpt
new file mode 100644
index 000000000000..82aafd3e1d55
--- /dev/null
+++ b/ext/html/tests/syntax_bc.phpt
@@ -0,0 +1,46 @@
+--TEST--
+Markup syntax: bare "<" in operator position keeps comparison/shift/not-equal meaning
+--EXTENSIONS--
+html
+--FILE--
+ $b); // legacy not-equal
+var_dump($a <=> $b); // spaceship
+var_dump($a <= $b); // less-or-equal
+var_dump(1 << 4); // shift left
+var_dump(64 >> 2); // shift right
+
+// Heredoc (which also starts with "<") is unaffected.
+echo << text
+EOT, "\n";
+
+// Error suppression still works.
+$x = []; var_dump(@$x['missing']);
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+int(-1)
+bool(true)
+int(16)
+int(16)
+literal text
+NULL
diff --git a/ext/html/tests/syntax_braces.phpt b/ext/html/tests/syntax_braces.phpt
new file mode 100644
index 000000000000..8babd1bfc2f9
--- /dev/null
+++ b/ext/html/tests/syntax_braces.phpt
@@ -0,0 +1,30 @@
+--TEST--
+Markup syntax: literal braces via character references or {'{'} (RFC §6)
+--EXTENSIONS--
+html
+--FILE--
+{x})->__toString(), "\n";
+echo (
{x}
)->__toString(), "\n";
+
+// The JSX form: an interpolated string literal.
+echo (
{'{'}x{'}'}
)->__toString(), "\n";
+
+// A lone } in text is already literal.
+echo (
a } b
)->__toString(), "\n";
+
+// Literal JSON in a )->__toString(), "\n";
+?>
+--EXPECT--
+
{x}
+
{x}
+
{x}
+
a } b
+
diff --git a/ext/html/tests/syntax_childless.phpt b/ext/html/tests/syntax_childless.phpt
new file mode 100644
index 000000000000..1c6d0ddedd92
--- /dev/null
+++ b/ext/html/tests/syntax_childless.phpt
@@ -0,0 +1,27 @@
+--TEST--
+Markup syntax: childless elements may be written empty or self-closed (RFC §6)
+--EXTENSIONS--
+html
+--FILE--
+
and are equivalent - both allowed, so real HTML can be
+// pasted into markup unchanged.
+var_dump((string) === (string) );
+echo , "\n";
+echo , "\n";
+
+// A whitespace-only multi-line body normalizes away to the same empty element.
+echo
+
, "\n";
+
+// Void elements still serialize with no closing tag, from either source form.
+echo , "\n";
+echo , "\n";
+?>
+--EXPECT--
+bool(true)
+
+
+
+
+
diff --git a/ext/html/tests/syntax_comments.phpt b/ext/html/tests/syntax_comments.phpt
new file mode 100644
index 000000000000..a0f3fa6cf0e7
--- /dev/null
+++ b/ext/html/tests/syntax_comments.phpt
@@ -0,0 +1,28 @@
+--TEST--
+Markup syntax: is a literal comment; {/* */} renders nothing (RFC §7)
+--EXTENSIONS--
+html
+--FILE--
+ is emitted verbatim; its content is literal (no interpolation).
+echo (
body
)->__toString(), "\n";
+
+// {/* ... */} is a source-only comment and renders nothing.
+echo (
a{/* dropped */}b
)->__toString(), "\n";
+
+// an empty interpolation renders nothing too
+echo (
x{}y
)->__toString(), "\n";
+
+// comment between block elements survives (it is real output, not whitespace)
+echo (
+
+
one
+
)->__toString(), "\n";
+?>
+--EXPECT--
+
body
+
ab
+
xy
+
one
diff --git a/ext/html/tests/syntax_component_hyphenated.phpt b/ext/html/tests/syntax_component_hyphenated.phpt
new file mode 100644
index 000000000000..796e5e417d15
--- /dev/null
+++ b/ext/html/tests/syntax_component_hyphenated.phpt
@@ -0,0 +1,28 @@
+--TEST--
+Markup syntax: hyphenated attributes on components route through named args (RFC §5)
+--EXTENSIONS--
+html
+--FILE--
+ $kind, ...$attrs], ['box']);
+}
+
+// Hyphenated attributes work directly on a component (no spread needed) - they
+// become named arguments and the variadic collects them.
+echo (), "\n";
+
+// A component without a variadic rejects an unknown (hyphenated) attribute.
+function Tight(string $kind): Html\Htmlable { return new E('div', ['class' => $kind], ['y']); }
+try {
+ echo (), "\n";
+} catch (\Throwable $e) {
+ echo get_class($e), ': ', $e->getMessage(), "\n";
+}
+?>
+--EXPECT--
+
box
+Error: Unknown named parameter $data-x
diff --git a/ext/html/tests/syntax_component_namespace.phpt b/ext/html/tests/syntax_component_namespace.phpt
new file mode 100644
index 000000000000..7ee22068f0cc
--- /dev/null
+++ b/ext/html/tests/syntax_component_namespace.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Markup syntax: component names are resolved at compile time honoring use/namespace (RFC §4)
+--EXTENSIONS--
+html
+--FILE--
+ 'card'], [$this->title]);
+ }
+ }
+}
+
+namespace App\Views {
+ use App\Components\Card;
+ // resolves to App\Components\Card via the `use` alias, not a global Card.
+ echo ()->__toString(), "\n";
+ echo (
)->__toString(), "\n";
+}
+
+namespace Other {
+ // A fully-qualified-feeling name via current namespace: define a local component.
+ use Html\Element as E;
+ class Box implements \Html\Htmlable {
+ public function __construct(public string $label) {}
+ public function toHtml(): \Html\Htmlable { return new E('b', [], [$this->label]); }
+ }
+ echo ()->__toString(), "\n";
+}
+?>
+--EXPECT--
+
Hi & ok
+
Nested
+local
diff --git a/ext/html/tests/syntax_component_namespace_fallback.phpt b/ext/html/tests/syntax_component_namespace_fallback.phpt
new file mode 100644
index 000000000000..4e10eb3a835b
--- /dev/null
+++ b/ext/html/tests/syntax_component_namespace_fallback.phpt
@@ -0,0 +1,40 @@
+--TEST--
+Markup syntax: unqualified function components fall back to the global namespace (RFC §4)
+--EXTENSIONS--
+html
+--FILE--
+
+ // inside a namespace falls back to the global function.
+ echo ()->__toString(), "\n";
+}
+
+namespace App2 {
+ function greeting(string $name): \Html\Htmlable {
+ return \Html\raw("Hello, {$name} (App2)");
+ }
+ // A same-named function in the current namespace wins over the global one.
+ echo ()->__toString(), "\n";
+}
+
+namespace Deco {
+ // Decorators receive the function actually called - here the global
+ // fallback candidate, not the unresolved Deco\Greeting candidate.
+ \Html\register_component_decorator(
+ fn(\Html\Htmlable $h, string $c): \Html\Htmlable
+ => \Html\raw((string) $h . " [decorated: {$c}]")
+ );
+ echo ()->__toString(), "\n";
+}
+?>
+--EXPECT--
+Hello, Liam (global)
+Hello, Liam (App2)
+Hello, Deco (global) [decorated: Greeting]
diff --git a/ext/html/tests/syntax_component_qualified.phpt b/ext/html/tests/syntax_component_qualified.phpt
new file mode 100644
index 000000000000..c717abb84eae
--- /dev/null
+++ b/ext/html/tests/syntax_component_qualified.phpt
@@ -0,0 +1,58 @@
+--TEST--
+Markup syntax: qualified/fully-qualified component tags and use-function resolution (RFC §4)
+--EXTENSIONS--
+html
+--FILE--
+{$slot};
+ }
+ class Card implements \Html\Htmlable {
+ public function __construct(public string $title) {}
+ public function toHtml(): \Html\Htmlable {
+ return new \Html\Element('b', [], [$this->title]);
+ }
+ }
+}
+
+namespace {
+ // A qualified name is namespace-relative; from global scope App\Greeting is App\Greeting.
+ echo (hi)->__toString(), "\n";
+ echo ()->__toString(), "\n";
+}
+
+namespace Views {
+ // A fully-qualified <\...> tag ignores the current namespace.
+ echo (<\App\Greeting>fq\App\Greeting>)->__toString(), "\n";
+
+ // A function component is imported with `use function` (not `use`).
+ use function App\Greeting;
+ echo (uf)->__toString(), "\n";
+
+ // A class component is imported with a class `use`.
+ use App\Card;
+ echo ()->__toString(), "\n";
+}
+
+namespace Wrong {
+ // A class `use` does NOT bring a *function* component into scope: markup
+ // resolves the class candidate (App\Greeting, not a class) and the function
+ // candidate (Wrong\Greeting, no such function) and rejects both, exactly as
+ // ordinary PHP keeps class and function name resolution separate.
+ use App\Greeting;
+ try {
+ echo (x)->__toString(), "\n";
+ } catch (\Error $e) {
+ echo $e->getMessage(), "\n";
+ }
+}
+?>
+--EXPECT--
+
hi
+G
+
fq
+
uf
+C
+"App\Greeting" is not a component: no class implementing Html\Htmlable and no user-defined function of that name
diff --git a/ext/html/tests/syntax_components.phpt b/ext/html/tests/syntax_components.phpt
new file mode 100644
index 000000000000..cf739efc1e69
--- /dev/null
+++ b/ext/html/tests/syntax_components.phpt
@@ -0,0 +1,57 @@
+--TEST--
+Markup syntax: component tags lower to Html\render_component (RFC §4)
+--EXTENSIONS--
+html
+--FILE--
+ 'card'], [new E('h2', [], [$this->title]), $this->body]);
+ }
+}
+
+// Function component.
+function Badge(string $label): Html\Htmlable {
+ return new E('span', ['class' => 'badge'], [$label]);
+}
+
+// Static-method component.
+class Author {
+ public static function byline(string $name): Html\Htmlable {
+ return new E('p', ['class' => 'by'], ['By ', $name]);
+ }
+}
+
+$who = 'Ada & co';
+
+// class component: attribute prop + body routed to the anonymous slot
+echo (
Hi {$who}
)->__toString(), "\n";
+
+// self-closing class component (no body -> slot is null)
+echo ()->__toString(), "\n";
+
+// function component
+echo ()->__toString(), "\n";
+
+// static-method component
+echo ()->__toString(), "\n";
+
+// a component used as a child of an HTML element, and inside interpolation
+echo (
)->__toString(), "\n";
+echo (
{array_map(fn($t) => , ['a', 'b'])}
)->__toString(), "\n";
+?>
+--EXPECT--
+
Ada & co
Hi Ada & co
+
Solo
+New &
+
By Ada & co
+
x
+
ab
diff --git a/ext/html/tests/syntax_doctype.phpt b/ext/html/tests/syntax_doctype.phpt
new file mode 100644
index 000000000000..523ed6c1a7ce
--- /dev/null
+++ b/ext/html/tests/syntax_doctype.phpt
@@ -0,0 +1,28 @@
+--TEST--
+Markup syntax: is allowed in content position and emitted verbatim (RFC §6)
+--EXTENSIONS--
+html
+--FILE--
+
+
+
hi
+>)->__toString(), "\n";
+
+// Case-insensitive, and content is literal (no interpolation).
+echo (<>>)->__toString(), "\n";
+
+// Legacy doctypes with public/system identifiers pass through too.
+echo (<>
x
>)->__toString(), "\n";
+
+// Position is not validated: emitted where written, like a comment.
+echo (
s
)->__toString(), "\n";
+?>
+--EXPECT--
+
hi
+
+
x
+
s
diff --git a/ext/html/tests/syntax_doctype_errors.phpt b/ext/html/tests/syntax_doctype_errors.phpt
new file mode 100644
index 000000000000..0024ab93db9a
--- /dev/null
+++ b/ext/html/tests/syntax_doctype_errors.phpt
@@ -0,0 +1,27 @@
+--TEST--
+Markup syntax: non-doctype ;');
+} catch (ParseError $e) {
+ echo $e->getMessage(), "\n";
+}
+try {
+ eval('$v = ;');
+} catch (ParseError $e) {
+ echo $e->getMessage(), "\n";
+}
+// An unterminated doctype must not scan past the end of the file.
+try {
+ eval('$v =
getMessage(), "\n";
+}
+?>
+--EXPECTF--
+Unsupported markup declaration; only comments and are supported
+Unsupported markup declaration; only comments and are supported
+%s
diff --git a/ext/html/tests/syntax_dynamic.phpt b/ext/html/tests/syntax_dynamic.phpt
new file mode 100644
index 000000000000..f55f0af9c05d
--- /dev/null
+++ b/ext/html/tests/syntax_dynamic.phpt
@@ -0,0 +1,112 @@
+--TEST--
+Markup syntax: dynamic tags <$tag>…$tag> (RFC §4 variable elements/components)
+--EXTENSIONS--
+html
+--FILE--
+Hello$tag>;
+var_dump($el instanceof Html\Element);
+echo $el, "\n";
+
+// Self-close; void elements serialize as voids.
+$tag = 'br';
+echo <$tag/>, "\n";
+$tag = 'section';
+echo <$tag id="main" hidden>Text {1 + 1}$tag>, "\n";
+
+// Nested inside static markup, with spread attributes.
+$tag = 'em';
+$attrs = ['class' => 'x'];
+echo
<$tag {...$attrs}>hi$tag>
, "\n";
+
+// A class-component name (::class gives the FQCN): full component dispatch.
+class Card implements Html\Htmlable {
+ public function __construct(
+ private string $title,
+ #[Html\Slot] private ?Html\Htmlable $body = null,
+ ) {}
+ public function toHtml(): Html\Htmlable {
+ return
{$this->title}
{$this->body};
+ }
+}
+$component = Card::class;
+echo <$component title="Hi"/>, "\n";
+echo <$component title="Hi">Body$component>, "\n";
+
+// A function component: capitalized value, resolved like a direct
+// render_component() call (class first, then function).
+function greeting(string $name): Html\Htmlable {
+ return
Hello {$name}
;
+}
+$component = 'Greeting';
+echo <$component name="Liam"/>, "\n";
+
+// Named slots route into a dynamic component like a static one.
+class Layout implements Html\Htmlable {
+ public function __construct(
+ #[Html\Slot] private Html\Htmlable $body,
+ #[Html\Slot('footer')] private Html\Htmlable $footer,
+ ) {}
+ public function toHtml(): Html\Htmlable {
+ return
{$this->body}
;
+ }
+}
+$component = Layout::class;
+echo <$component>MainBye$component>, "\n";
+
+// Interpolated content is still escaped, whatever the tag value.
+$tag = 'span';
+$evil = '';
+echo <$tag>{$evil}$tag>, "\n";
+
+// The brace form takes an arbitrary expression, evaluated once, and closes
+// anonymously with > (or self-closes).
+class Registry {
+ public function getComponentName(): string { return 'div'; }
+}
+$object = new Registry;
+echo <{$object->getComponentName()} class="x">Hello>, "\n";
+$important = true;
+echo <{$important ? 'strong' : 'span'}>note>, "\n";
+echo <{Card::class} title="Braced"/>, "\n";
+
+$calls = 0;
+function tag_once(): string { global $calls; $calls++; return 'p'; }
+echo <{tag_once()}>once>, "\n";
+var_dump($calls);
+
+// Brace tags nest like any other element.
+echo
<{'em'}>in here>
, "\n";
+
+// In operator position "<$" keeps its comparison meaning.
+$a = 5;
+$b = 9;
+var_dump($a <$b, $b <$a);
+
+// The runtime target is a public function, same as render_component().
+echo Html\render_dynamic('p', ['class' => 'y'], ['direct']), "\n";
+echo Html\render_dynamic(Card::class, ['title' => 'Direct']), "\n";
+?>
+--EXPECT--
+bool(true)
+
Hello
+
+Text 2
+
hi
+
Hi
+
Hi
Body
+
Hello Liam
+
Main
+<script>alert(1)</script>
+
Hello
+note
+
Braced
+
once
+int(1)
+
in here
+bool(true)
+bool(false)
+
direct
+
Direct
diff --git a/ext/html/tests/syntax_dynamic_errors.phpt b/ext/html/tests/syntax_dynamic_errors.phpt
new file mode 100644
index 000000000000..421c9a128012
--- /dev/null
+++ b/ext/html/tests/syntax_dynamic_errors.phpt
@@ -0,0 +1,78 @@
+--TEST--
+Markup syntax: dynamic tag error cases (mismatched close, slots on elements, invalid names)
+--EXTENSIONS--
+html
+--FILE--
+ for .
+try {
+ eval('$x = <$a>hi$b>;');
+} catch (CompileError $e) {
+ echo $e->getMessage(), "\n";
+}
+
+// A brace tag closes anonymously; a named or variable close is a parse error
+// (the expression cannot be restated without re-evaluating it).
+foreach (['$x = <{"div"}>a
;', '$x = <{"div"}>a$t>;'] as $code) {
+ try {
+ eval($code);
+ } catch (ParseError $e) {
+ echo $e->getMessage(), "\n";
+ }
+}
+
+// A block against an element-classified value has nowhere to go.
+$tag = 'div';
+try {
+ $x = <$tag>x$tag>;
+} catch (Error $e) {
+ echo $e->getMessage(), "\n";
+}
+
+// An empty tag value is rejected up front.
+try {
+ Html\render_dynamic('');
+} catch (ValueError $e) {
+ echo $e->getMessage(), "\n";
+}
+
+// A value that is no valid tag name fails at serialization (the existing
+// Element guard) - a dynamic name can never break out of the markup.
+$tag = 'di v';
+try {
+ echo (string) <$tag/>;
+} catch (Error $e) {
+ echo $e->getMessage(), "\n";
+}
+
+// A lowercase value is always an element, even if a function of that name
+// exists (the same rule that makes static an element).
+$tag = 'date';
+echo <$tag/>, "\n";
+
+// A capitalized value resolving to an internal function hits the footgun guard.
+$tag = 'Date';
+try {
+ echo <$tag/>;
+} catch (Error $e) {
+ echo $e->getMessage(), "\n";
+}
+
+// A capitalized value with no symbol behind it.
+$tag = 'NoSuchComponent';
+try {
+ echo <$tag/>;
+} catch (Error $e) {
+ echo $e->getMessage(), "\n";
+}
+?>
+--EXPECT--
+Mismatched markup closing tag: expected $a>, found $b>
+syntax error, unexpected markup tag name "div", expecting markup tag end
+syntax error, unexpected variable "$t", expecting markup tag end
+Markup slot blocks are only allowed in a component body; "div" is an HTML element
+Html\render_dynamic(): Argument #1 ($tag) cannot be empty
+Invalid tag name "di v"
+
+ resolved to the internal function Date(), which cannot be a component
+"NoSuchComponent" is not a component: no class implementing Html\Htmlable and no user-defined function of that name
diff --git a/ext/html/tests/syntax_entities.phpt b/ext/html/tests/syntax_entities.phpt
new file mode 100644
index 000000000000..bf241ad4263a
--- /dev/null
+++ b/ext/html/tests/syntax_entities.phpt
@@ -0,0 +1,55 @@
+--TEST--
+Markup syntax: HTML character references decode in text and attribute values (RFC §7)
+--EXTENSIONS--
+html
+--FILE--
+Fish & chips — £5, "\n";
+
+// Case matters, exactly as in HTML: Á and á differ.
+echo
, "\n";
+
+// is a real U+00A0, not the entity text (htmlspecialchars leaves it alone).
+var_dump((string) === "\u{00A0}");
+
+// Lenient like HTML/JSX: unknown names, bare "&", missing semicolons,
+// and invalid numerics (zero, surrogate, out of range, empty) stay literal
+// and are escaped on output.
+echo
a & b &nosuchentity; & c
, "\n";
+echo
G;
, "\n";
+
+// Attribute values decode too; the "&" re-escapes when serialized.
+echo x, "\n";
+
+// lt/gt decode to real angle brackets in the value, escaped on output --
+// so entities cannot smuggle markup structure.
+echo
<script>alert(1)</script>
, "\n";
+
+// Entities decode after whitespace normalization, so survives where a
+// literal trailing space would be trimmed (the entity spelling of JSX {' '}).
+echo
a
+
, "\n";
+
+// Entities inside stay verbatim (comments are raw output).
+echo , "\n";
+
+// Interpolated strings are never entity-decoded; they are plain PHP values.
+echo
+)->__toString(), "\n";
+
+// A meaningful single space between inline content is preserved.
+$name = 'Ada';
+echo (
Hello {$name}, welcome back
)->__toString(), "\n";
+
+// Leading/trailing blank lines and indentation around text collapse away.
+echo (
+ Just some text.
+
)->__toString(), "\n";
+
+// Words on separate lines join with a single space.
+echo (
+ one
+ two
+ three
+
)->__toString(), "\n";
+
+// Inline trailing space with no newline is kept (so adjacent elements don't fuse).
+echo (
a b c
)->__toString(), "\n";
+?>
+--EXPECT--
+
one
two
+
Hello Ada, welcome back
+
Just some text.
+
one two three
+
a b c
diff --git a/ext/html/tests/transpile.phpt b/ext/html/tests/transpile.phpt
new file mode 100644
index 000000000000..76b4a82291e5
--- /dev/null
+++ b/ext/html/tests/transpile.phpt
@@ -0,0 +1,49 @@
+--TEST--
+Html\transpile() exports the plain-PHP lowering of markup expressions
+--EXTENSIONS--
+html
+--FILE--
+Hello {$name}
;
+CODE);
+
+// Fragment, component with props + spread, named slot, nested element.
+echo Html\transpile(<<<'CODE'
+fbody>;
+CODE);
+
+// Character references are decoded at compile time, so the exported string
+// literal contains the decoded text.
+echo Html\transpile(<<<'CODE'
+Fish & chips — £5
;
+CODE);
+
+// Non-markup code passes through as ordinary exported source.
+echo Html\transpile(<<<'CODE'
+;');
+} catch (ParseError $e) {
+ echo "ParseError: ", $e->getMessage(), "\n";
+}
+?>
+--EXPECT--
+echo new \Html\Element('div', ['class' => 'box', 'id' => $id], ['Hello ', $name]);
+$page = new \Html\Fragment([\Html\render_component(Card::class, ['title' => 'Hi', ...$attrs], new \Html\Fragment(['body']), ['footer' => new \Html\Fragment([new \Html\Element('b', [], ['f'])])], 'Card')]);
+echo new \Html\Element('p', [], ['Fish & chips — £5']);
+function double(int $x): int {
+ return $x * 2;
+}
+
+ParseError: syntax error, unexpected end of file
diff --git a/ext/tokenizer/tokenizer_data.c b/ext/tokenizer/tokenizer_data.c
index 87b15b8bb345..a0b53bc282f6 100644
--- a/ext/tokenizer/tokenizer_data.c
+++ b/ext/tokenizer/tokenizer_data.c
@@ -175,6 +175,17 @@ char *get_token_type_name(int token_type)
case T_AMPERSAND_FOLLOWED_BY_VAR_OR_VARARG: return "T_AMPERSAND_FOLLOWED_BY_VAR_OR_VARARG";
case T_AMPERSAND_NOT_FOLLOWED_BY_VAR_OR_VARARG: return "T_AMPERSAND_NOT_FOLLOWED_BY_VAR_OR_VARARG";
case T_BAD_CHARACTER: return "T_BAD_CHARACTER";
+ case T_MARKUP_NAME: return "T_MARKUP_NAME";
+ case T_MARKUP_SLOT_NAME: return "T_MARKUP_SLOT_NAME";
+ case T_MARKUP_TEXT: return "T_MARKUP_TEXT";
+ case T_MARKUP_ATTR_VALUE: return "T_MARKUP_ATTR_VALUE";
+ case T_MARKUP_COMMENT: return "T_MARKUP_COMMENT";
+ case T_MARKUP_OPEN: return "T_MARKUP_OPEN";
+ case T_MARKUP_CLOSE_OPEN: return "T_MARKUP_CLOSE_OPEN";
+ case T_MARKUP_TAG_END: return "T_MARKUP_TAG_END";
+ case T_MARKUP_SELF_CLOSE: return "T_MARKUP_SELF_CLOSE";
+ case T_MARKUP_INTERP_START: return "T_MARKUP_INTERP_START";
+ case T_MARKUP_DOCTYPE: return "T_MARKUP_DOCTYPE";
}
return NULL;
diff --git a/ext/tokenizer/tokenizer_data.stub.php b/ext/tokenizer/tokenizer_data.stub.php
index 57c8edad8acb..cc95d3d14089 100644
--- a/ext/tokenizer/tokenizer_data.stub.php
+++ b/ext/tokenizer/tokenizer_data.stub.php
@@ -762,6 +762,61 @@
* @cvalue T_BAD_CHARACTER
*/
const T_BAD_CHARACTER = UNKNOWN;
+/**
+ * @var int
+ * @cvalue T_MARKUP_NAME
+ */
+const T_MARKUP_NAME = UNKNOWN;
+/**
+ * @var int
+ * @cvalue T_MARKUP_SLOT_NAME
+ */
+const T_MARKUP_SLOT_NAME = UNKNOWN;
+/**
+ * @var int
+ * @cvalue T_MARKUP_TEXT
+ */
+const T_MARKUP_TEXT = UNKNOWN;
+/**
+ * @var int
+ * @cvalue T_MARKUP_ATTR_VALUE
+ */
+const T_MARKUP_ATTR_VALUE = UNKNOWN;
+/**
+ * @var int
+ * @cvalue T_MARKUP_COMMENT
+ */
+const T_MARKUP_COMMENT = UNKNOWN;
+/**
+ * @var int
+ * @cvalue T_MARKUP_OPEN
+ */
+const T_MARKUP_OPEN = UNKNOWN;
+/**
+ * @var int
+ * @cvalue T_MARKUP_CLOSE_OPEN
+ */
+const T_MARKUP_CLOSE_OPEN = UNKNOWN;
+/**
+ * @var int
+ * @cvalue T_MARKUP_TAG_END
+ */
+const T_MARKUP_TAG_END = UNKNOWN;
+/**
+ * @var int
+ * @cvalue T_MARKUP_SELF_CLOSE
+ */
+const T_MARKUP_SELF_CLOSE = UNKNOWN;
+/**
+ * @var int
+ * @cvalue T_MARKUP_INTERP_START
+ */
+const T_MARKUP_INTERP_START = UNKNOWN;
+/**
+ * @var int
+ * @cvalue T_MARKUP_DOCTYPE
+ */
+const T_MARKUP_DOCTYPE = UNKNOWN;
/**
* @var int
* @cvalue T_PAAMAYIM_NEKUDOTAYIM
diff --git a/ext/tokenizer/tokenizer_data_arginfo.h b/ext/tokenizer/tokenizer_data_arginfo.h
index b82842ede0f1..fcb42e39427c 100644
--- a/ext/tokenizer/tokenizer_data_arginfo.h
+++ b/ext/tokenizer/tokenizer_data_arginfo.h
@@ -1,5 +1,5 @@
/* This is a generated file, edit tokenizer_data.stub.php instead.
- * Stub hash: c5235344b7c651d27c2c33c90696a418a9c96837 */
+ * Stub hash: d9fd6ea8011a46bab43f944a422835a956b0e6cc */
static void register_tokenizer_data_symbols(int module_number)
{
@@ -155,5 +155,16 @@ static void register_tokenizer_data_symbols(int module_number)
REGISTER_LONG_CONSTANT("T_AMPERSAND_FOLLOWED_BY_VAR_OR_VARARG", T_AMPERSAND_FOLLOWED_BY_VAR_OR_VARARG, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("T_AMPERSAND_NOT_FOLLOWED_BY_VAR_OR_VARARG", T_AMPERSAND_NOT_FOLLOWED_BY_VAR_OR_VARARG, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("T_BAD_CHARACTER", T_BAD_CHARACTER, CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("T_MARKUP_NAME", T_MARKUP_NAME, CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("T_MARKUP_SLOT_NAME", T_MARKUP_SLOT_NAME, CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("T_MARKUP_TEXT", T_MARKUP_TEXT, CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("T_MARKUP_ATTR_VALUE", T_MARKUP_ATTR_VALUE, CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("T_MARKUP_COMMENT", T_MARKUP_COMMENT, CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("T_MARKUP_OPEN", T_MARKUP_OPEN, CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("T_MARKUP_CLOSE_OPEN", T_MARKUP_CLOSE_OPEN, CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("T_MARKUP_TAG_END", T_MARKUP_TAG_END, CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("T_MARKUP_SELF_CLOSE", T_MARKUP_SELF_CLOSE, CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("T_MARKUP_INTERP_START", T_MARKUP_INTERP_START, CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("T_MARKUP_DOCTYPE", T_MARKUP_DOCTYPE, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("T_DOUBLE_COLON", T_PAAMAYIM_NEKUDOTAYIM, CONST_PERSISTENT);
}
diff --git a/win32/build/config.w32 b/win32/build/config.w32
index a0f26033306f..eb4ff84ddaef 100644
--- a/win32/build/config.w32
+++ b/win32/build/config.w32
@@ -241,7 +241,7 @@ ADD_SOURCES("Zend", "zend_language_parser.c zend_language_scanner.c \
zend_float.c zend_string.c zend_generators.c zend_virtual_cwd.c zend_ast.c \
zend_inheritance.c zend_smart_str.c zend_cpuinfo.c zend_observer.c zend_system_id.c \
zend_enum.c zend_fibers.c zend_atomic.c zend_hrtime.c zend_frameless_function.c zend_property_hooks.c \
- zend_lazy_objects.c zend_autoload.c");
+ zend_lazy_objects.c zend_autoload.c zend_markup.c");
ADD_SOURCES("Zend\\Optimizer", "zend_optimizer.c pass1.c pass3.c optimize_func_calls.c block_pass.c optimize_temp_vars_5.c nop_removal.c compact_literals.c zend_cfg.c zend_dfg.c dfa_pass.c zend_ssa.c zend_inference.c zend_func_info.c zend_call_graph.c zend_dump.c escape_analysis.c compact_vars.c dce.c sccp.c scdf.c");
var PHP_ASSEMBLER = PATH_PROG({
From c878e01941766a49afa2479beeb847ab9efdf494 Mon Sep 17 00:00:00 2001
From: Liam Hammett
Date: Thu, 9 Jul 2026 02:21:17 +0100
Subject: [PATCH 02/17] Additional clarifications
---
ext/html/RFC.md | 20 ++++++++------------
1 file changed, 8 insertions(+), 12 deletions(-)
diff --git a/ext/html/RFC.md b/ext/html/RFC.md
index 778c696d2a54..b672376a4b5e 100644
--- a/ext/html/RFC.md
+++ b/ext/html/RFC.md
@@ -1,4 +1,4 @@
-# PHP RFC: Native Markup Expressions (JSX-style syntax)
+# Native Markup Expressions (JSX-style syntax)
## Introduction
@@ -40,7 +40,7 @@ The two headline benefits over every existing option are **composition** (compon
### Why this is worth doing
-Generating HTML is not a niche PHP task - it is arguably *the* PHP task, and essentially every major framework has independently built the same answer to it: an escape-by-default, composable template engine. Their adoption is enormous - on Packagist, `laravel/framework` (Blade) has over **540 million** installs and `twig/twig` over **460 million**, before counting Latte, Smarty, Plates, or hand-rolled templates that never appear in a dependency graph.
+Generating HTML is not a niche PHP task - it is arguably *the* PHP task, and essentially every major framework has independently built the same answer to it: an escape-by-default, composable template engine. Their adoption is enormous - on Packagist, `laravel/framework` (Blade) has over **540 million** installs and `twig/twig` over **460 million**, before counting Latte, Smarty, Plates, or hand-rolled templates that never appear in a dependency graph.
That every engine converged on the same two features is strong evidence the need is real and the language does not meet it: developers reach for a third-party compiler, a separate dialect, and a build step to obtain what the runtime could provide natively. And escape-by-default is no mere convenience - cross-site scripting has sat in the OWASP Top 10 for its entire existence, and automatic HTML escaping is one of the disciplines that prevents it.
@@ -222,7 +222,7 @@ The value is classified **at runtime by exactly the compile-time rule**: a strin
Closing follows the form:
- A variable tag closes by repeating the same variable such as `<$a>...$a>`
- - `<$a>...$b>` is a compile error, exactly as `
...` is
+ - `<$a>...$b>` is a compile error, exactly as `
...` is
- An expression tag with a brace closes **anonymously** with `>`
- A closing tag cannot restate the expression - re-evaluating it would run its side effects twice, and matching it textually is meaningless for a computed value; the expression is evaluated exactly once.
- A self-closing `<$tag/>` / `<{expr}/>` needs no closer at all.
@@ -528,7 +528,7 @@ class Clock extends Component
}
```
-## Parsing the `<` ambiguity
+## Backward Incompatible Changes
`<` is heavily overloaded in PHP (`<`, `<=`, `<=>`, `<<`, `<>`, `<<<`), so this was the main design risk. It is resolved by the same technique JavaScript uses to tell regex from division: the scanner tracks whether it is in **operand position** (a value is expected) or **operator position** (a value just ended). Markup begins only when `<` is in operand position *and* is immediately followed by `[A-Za-z>]`, by `\` then a letter (a fully-qualified component tag such as `<\App\Card/>`), by `$` then a variable name, or by `{` (the dynamic tags `<$tag>` / `<{expr}>`):
@@ -536,15 +536,11 @@ class Clock extends Component
* In `$a < \Foo::BAR`, `<` is in operator position → comparison, unchanged.
* In `return
`, `<` is in operand position followed by a letter → markup.
-Because `<` followed by a letter, `$`, `{`, or `>` in operand position is *always a syntax error* in PHP today, reclaiming it does not change the meaning of any valid program. Every operand-ending context (`static`, `exit`, bare `yield`, postfix `++`/`--`, `::class`, closing `)`/`]`/`}`, heredoc terminators, string closers) is explicitly kept as a comparison, which compare the received value today and still do.
-
-This is **implemented and verified**: the full Zend and PHP language test suites pass with **zero regressions** (6000+ tests), and `token_get_all()` continues to tokenize ordinary code identically (the operand/operator tracking is one extra store per token, with no measurable overhead).
+Because `<` followed by a letter, `$`, `{`, or `>` in operand position is *always a syntax error* in PHP today, reclaiming it does not change the meaning of any valid program. Every operand-ending context is explicitly kept as a comparison, which compare the received value today and still do. `<>` is the legacy alias of `!=` and is preserved in infix position (`$a <> $b` is still not-equal). `<>` is only reinterpreted as a fragment opener in **operand position**, where it is currently a syntax error - so no valid program needs changes.
-## Backward Incompatible Changes
+This leaves just one backwards compatibility concern:
-1. **`<>` operator** - `<>` is the legacy alias of `!=`. It is **preserved in infix position** (`$a <> $b` is still not-equal). `<>` is only reinterpreted as a fragment opener in **operand position**, where it is currently a syntax error - so no valid program needs changes.
-2. **`<` in operand position followed by a letter** (or by `\`+letter for a fully-qualified component tag, or by `$`+name / `{` for a dynamic tag) - currently a syntax error in such positions; reclaimed for markup, so no valid program needs changes.
-3. **New `Html\` namespace** - introduces `Html\Htmlable`, `Html\Element`, `Html\Fragment`, `Html\Raw`, `Html\Slot`, `Html\raw()`, `Html\escape()`. Top-level `Html\` follows the established precedent for bundled extensions (`Random\`, `Dom\`, `Uri\`), and real-world top-level `Html\` packages are rare. No existing keywords are reserved and no new global reserved words are introduced (the syntax uses bare `<` and a `slot:` form valid only inside markup).
+1. **New `Html\` namespace** - introduces `Html\Htmlable`, `Html\Element`, `Html\Fragment`, `Html\Raw`, `Html\Slot`, `Html\raw()`, `Html\escape()`. Top-level `Html\` follows the established precedent for bundled extensions (`Random\`, `Dom\`, `Uri\`), and real-world top-level `Html\` packages are rare. No existing keywords are reserved and no new global reserved words are introduced (the syntax uses bare `<` and a `slot:` form valid only inside markup).
### Impact on tooling
@@ -590,4 +586,4 @@ Some natural extensions are deliberately left out of this RFC to keep its scope
* `phplang/xhp` - XHP for PHP 7+ as a community extension: https://github.com/phplang/xhp
* Internals discussion endorsing the XHP approach over an auto-escape flag (2018): https://externals.io/message/91797
* Adjacent string-interpolation RFCs: https://wiki.php.net/rfc/arbitrary_string_interpolation , https://wiki.php.net/rfc/arbitrary_expression_interpolation
-* Userland JSX-for-PHP experiment - https://github.com/attitude/phpx
+* Userland JSX-for-PHP experiment - https://github.com/attitude/phpx
\ No newline at end of file
From 3b9ac2b4b43f6f1188d3a6c7011ec75716f0ebca Mon Sep 17 00:00:00 2001
From: Liam Hammett
Date: Thu, 9 Jul 2026 18:09:08 +0100
Subject: [PATCH 03/17] Remove named slots (preferring straight attributes) and
add lazy slots
---
Zend/zend_language_parser.y | 11 +-
Zend/zend_language_scanner.l | 5 -
Zend/zend_markup.c | 185 ++++++---------
Zend/zend_markup.h | 14 +-
ext/html/RFC.md | 109 +++++----
ext/html/html.c | 267 +++++++++++++---------
ext/html/html.stub.php | 49 ++--
ext/html/html_arginfo.h | 39 ++--
ext/html/tests/component_slot_errors.phpt | 31 +--
ext/html/tests/component_slots.phpt | 19 +-
ext/html/tests/lazy_fragment.phpt | 59 +++++
ext/html/tests/slot_attribute.phpt | 11 +-
ext/html/tests/syntax_dynamic.phpt | 14 --
ext/html/tests/syntax_dynamic_errors.phpt | 11 +-
ext/html/tests/syntax_named_slots.phpt | 46 ----
ext/html/tests/syntax_slot_mismatch.phpt | 11 -
ext/html/tests/transpile.phpt | 13 +-
ext/tokenizer/tokenizer_data.c | 1 -
ext/tokenizer/tokenizer_data.stub.php | 5 -
ext/tokenizer/tokenizer_data_arginfo.h | 3 +-
20 files changed, 460 insertions(+), 443 deletions(-)
create mode 100644 ext/html/tests/lazy_fragment.phpt
delete mode 100644 ext/html/tests/syntax_named_slots.phpt
delete mode 100644 ext/html/tests/syntax_slot_mismatch.phpt
diff --git a/Zend/zend_language_parser.y b/Zend/zend_language_parser.y
index e6c2f5c82c8d..44258461052e 100644
--- a/Zend/zend_language_parser.y
+++ b/Zend/zend_language_parser.y
@@ -255,7 +255,6 @@ static YYSIZE_T zend_yytnamerr(char*, const char*);
* inserting them mid-list would renumber every later token (T_INLINE_HTML
* onward), breaking code that hardcodes token integers. */
%token T_MARKUP_NAME "markup tag name"
-%token T_MARKUP_SLOT_NAME "markup slot name"
%token T_MARKUP_TEXT "markup text"
%token T_MARKUP_ATTR_VALUE "markup attribute value"
%token T_MARKUP_COMMENT "markup comment"
@@ -299,7 +298,7 @@ static YYSIZE_T zend_yytnamerr(char*, const char*);
%type attributed_statement attributed_top_statement attributed_class_statement attributed_parameter
%type attribute_decl attribute attributes attribute_group namespace_declaration_name
%type match match_arm_list non_empty_match_arm_list match_arm match_arm_cond_list
-%type markup_element markup_children markup_child markup_slot
+%type markup_element markup_children markup_child
%type markup_attributes markup_attribute
%type enum_declaration_statement enum_backing_type enum_case enum_case_expr
%type function_name non_empty_member_modifiers
@@ -1312,14 +1311,6 @@ markup_child:
| T_MARKUP_INTERP_START expr '}' { $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, $2, NULL); }
| T_MARKUP_INTERP_START '}' { zval n; ZVAL_NULL(&n); $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, zend_ast_create_zval(&n), NULL); }
| markup_element { $$ = zend_ast_create(ZEND_AST_ARRAY_ELEM, $1, NULL); }
- | markup_slot { $$ = $1; }
-;
-
-markup_slot:
- T_MARKUP_OPEN T_MARKUP_SLOT_NAME markup_attributes T_MARKUP_TAG_END markup_children T_MARKUP_CLOSE_OPEN T_MARKUP_SLOT_NAME T_MARKUP_TAG_END
- { $$ = zend_ast_create_markup_slot($2, $3, $5, $7); if (!$$) { YYERROR; } }
- | T_MARKUP_OPEN T_MARKUP_SLOT_NAME markup_attributes T_MARKUP_SELF_CLOSE
- { $$ = zend_ast_create_markup_slot($2, $3, NULL, NULL); if (!$$) { YYERROR; } }
;
expr:
diff --git a/Zend/zend_language_scanner.l b/Zend/zend_language_scanner.l
index 490715becce8..26a6183326dc 100644
--- a/Zend/zend_language_scanner.l
+++ b/Zend/zend_language_scanner.l
@@ -2105,11 +2105,6 @@ OPTIONAL_WHITESPACE_OR_COMMENTS ({WHITESPACE}|{MULTI_LINE_COMMENT}|{SINGLE_LINE_
goto return_whitespace;
}
-"slot:"[a-zA-Z][a-zA-Z0-9_.-]* {
- /* a named-slot block …; strip the "slot:" prefix */
- RETURN_TOKEN_WITH_STR(T_MARKUP_SLOT_NAME, sizeof("slot:") - 1);
-}
-
"\\"?[a-zA-Z][a-zA-Z0-9_.:-]*("\\"[a-zA-Z][a-zA-Z0-9_.:-]*)* {
/* A tag name, optionally namespace-qualified () or fully
* qualified (<\App\Card/>). A name containing "\" always dispatches as a
diff --git a/Zend/zend_markup.c b/Zend/zend_markup.c
index 16f91e1dbd74..653cb3961b30 100644
--- a/Zend/zend_markup.c
+++ b/Zend/zend_markup.c
@@ -351,81 +351,62 @@ zend_ast *zend_ast_create_markup_raw(zend_ast *str)
zend_ast_create_list(1, ZEND_AST_ARG_LIST, str));
}
-/* Build a `…` block (RFC §6) as a *keyed* array element
- * `'name' => new \Html\Fragment([children])`. The component builder later routes
- * keyed elements to render_component's namedSlots argument. `close` is consumed. */
-zend_ast *zend_ast_create_markup_slot(zend_ast *open, zend_ast *attrs, zend_ast *children, zend_ast *close)
+/* Wrap a children list in
+ * `new \Html\LazyFragment(fn() => new \Html\Fragment([children]))` (RFC §5, the
+ * `:lazy` directive). The arrow function defers building the child subtree —
+ * and running any side effects in its interpolations — until the component
+ * actually renders the slot, so a component that discards its body never
+ * evaluates it. Consumes `children`. */
+static zend_ast *zend_ast_wrap_lazy_fragment(zend_ast *children, uint32_t lineno)
{
- if (close != NULL) {
- if (!zend_string_equals(Z_STR_P(zend_ast_get_zval(open)), Z_STR_P(zend_ast_get_zval(close)))) {
- zend_throw_exception_ex(zend_ce_compile_error, 0,
- "Mismatched markup closing tag: expected , found ",
- Z_STRVAL_P(zend_ast_get_zval(open)), Z_STRVAL_P(zend_ast_get_zval(close)));
- zend_ast_destroy(open);
- zend_ast_destroy(attrs);
- if (children != NULL) {
- zend_ast_destroy(children);
- }
- zend_ast_destroy(close);
- return NULL;
- }
- zend_ast_destroy(close);
- }
- /* A slot block routes body content only; silently discarding written
- * attributes would hide a mistake. */
- if (attrs != NULL && zend_ast_get_list(attrs)->children > 0) {
- zend_throw_exception_ex(zend_ce_compile_error, 0,
- "Markup slot cannot have attributes",
- Z_STRVAL_P(zend_ast_get_zval(open)));
- zend_ast_destroy(open);
- zend_ast_destroy(attrs);
- if (children != NULL) {
- zend_ast_destroy(children);
- }
- return NULL;
- }
- if (attrs != NULL) {
- zend_ast_destroy(attrs);
- }
- return zend_ast_create(ZEND_AST_ARRAY_ELEM, zend_ast_wrap_fragment(children), open);
+ zend_ast *frag = zend_ast_wrap_fragment(children);
+
+ /* fn() => new \Html\Fragment([...]) — an arrow function so its body
+ * auto-captures by value whatever it references, exactly as a hand-written
+ * one would; only the expression evaluation is deferred. */
+ zend_ast *params = zend_ast_create_list(0, ZEND_AST_PARAM_LIST);
+ zend_ast *closure = zend_ast_create_decl(ZEND_AST_ARROW_FUNC, 0, lineno,
+ NULL, NULL, params, NULL, frag, NULL, NULL);
+
+ zend_ast *lazy_class = zend_ast_markup_fq_name(ZEND_STRL(ZEND_MARKUP_LAZY_FRAGMENT_FQ));
+ return zend_ast_create(ZEND_AST_NEW, lazy_class,
+ zend_ast_create_list(1, ZEND_AST_ARG_LIST, closure));
}
-/* Partition a markup body into loose content and blocks: keyed
- * elements are named slots, everything else forms the anonymous slot (RFC §6).
- * Consumes `children` (may be NULL); both outputs are fresh ZEND_AST_ARRAY
- * lists with ZEND_ARRAY_SYNTAX_SHORT set. */
-static void zend_markup_partition_children(zend_ast *children, zend_ast **anon_out, zend_ast **named_out)
+/* Detect and strip a bare `:lazy` directive from a component's attribute list.
+ * `:lazy` marks the component's body slot for lazy evaluation (see
+ * zend_ast_wrap_lazy_fragment); it is a compiler directive, not a prop, so it is
+ * removed from the list rather than passed on. Returns whether it was present. */
+static bool zend_markup_extract_lazy(zend_ast *attrs)
{
- zend_ast *anon = zend_ast_create_list(0, ZEND_AST_ARRAY);
- zend_ast *named_slots = zend_ast_create_list(0, ZEND_AST_ARRAY);
-
- if (children != NULL) {
- zend_ast_list *clist = zend_ast_get_list(children);
- for (uint32_t i = 0; i < clist->children; i++) {
- zend_ast *elem = clist->child[i];
- if (elem != NULL && elem->kind == ZEND_AST_ARRAY_ELEM && elem->child[1] != NULL) {
- named_slots = zend_ast_list_add(named_slots, elem);
- } else {
- anon = zend_ast_list_add(anon, elem);
+ if (attrs == NULL) {
+ return false;
+ }
+ zend_ast_list *list = zend_ast_get_list(attrs);
+ for (uint32_t i = 0; i < list->children; i++) {
+ zend_ast *elem = list->child[i];
+ if (elem == NULL || elem->kind != ZEND_AST_ARRAY_ELEM || elem->child[1] == NULL) {
+ continue;
+ }
+ zval *key = zend_ast_get_zval(elem->child[1]);
+ if (Z_TYPE_P(key) == IS_STRING && zend_string_equals_literal(Z_STR_P(key), ":lazy")) {
+ zend_ast_destroy(elem);
+ for (uint32_t j = i; j + 1 < list->children; j++) {
+ list->child[j] = list->child[j + 1];
}
+ list->children--;
+ return true;
}
- /* Detach the moved elements before freeing the now-empty container. */
- clist->children = 0;
- zend_ast_destroy(children);
}
- anon->attr = ZEND_ARRAY_SYNTAX_SHORT;
- named_slots->attr = ZEND_ARRAY_SYNTAX_SHORT;
-
- *anon_out = anon;
- *named_out = named_slots;
+ return false;
}
/* Lower a component tag (capitalized name, namespace-qualified name, or
* "Class::method") into a call to
- * \Html\render_component(component, props, slot, namedSlots, functionComponent)
- * (RFC §3). Attributes become the props array; loose body content becomes a
- * single \Html\Fragment passed as the anonymous slot, and blocks
- * (keyed elements) are partitioned into the namedSlots array.
+ * \Html\render_component(component, props, slot, functionComponent)
+ * (RFC §3). Attributes become the props array and the body content becomes a
+ * single \Html\Fragment passed as the slot (or a lazy \Html\LazyFragment when
+ * the `:lazy` directive is present).
*
* Component resolution is two-stage. A bare tag could name either a class
* component or a function component, which PHP resolves through *separate* import
@@ -440,6 +421,7 @@ static void zend_markup_partition_children(zend_ast *children, zend_ast **anon_o
* PHP_FUNCTION(Html_render_component) in ext/html/html.c for the second stage. */
static zend_ast *zend_ast_create_markup_component(zend_ast *name, zend_ast *attrs, zend_ast *children)
{
+ uint32_t lineno = zend_ast_get_lineno(name);
zend_string *tag = zend_ast_get_str(name);
const char *sep = strstr(ZSTR_VAL(tag), "::");
/* A leading "\" is a fully-qualified reference; otherwise resolve the name
@@ -484,17 +466,21 @@ static zend_ast *zend_ast_create_markup_component(zend_ast *name, zend_ast *attr
}
attrs->attr = ZEND_ARRAY_SYNTAX_SHORT;
- /* Partition the body: keyed elements are blocks (named slots);
- * everything else is loose content forming the anonymous slot (RFC §6). */
- zend_ast *anon, *named_slots;
- zend_markup_partition_children(children, &anon, &named_slots);
+ /* The `:lazy` directive is a compiler marker, not a prop — strip it first. */
+ bool lazy = zend_markup_extract_lazy(attrs);
- /* Anonymous slot: a single Html\Fragment, or null when there is no loose body. */
+ /* The body becomes a single slot Fragment, or null when there is no body.
+ * With `:lazy` the fragment is wrapped in a deferred Html\LazyFragment. */
zend_ast *slot;
- if (zend_ast_get_list(anon)->children > 0) {
- slot = zend_ast_wrap_fragment(anon);
+ if (children != NULL && zend_ast_get_list(children)->children > 0) {
+ children->attr = ZEND_ARRAY_SYNTAX_SHORT;
+ slot = lazy
+ ? zend_ast_wrap_lazy_fragment(children, lineno)
+ : zend_ast_wrap_fragment(children);
} else {
- zend_ast_destroy(anon);
+ if (children != NULL) {
+ zend_ast_destroy(children);
+ }
zval null_zv;
ZVAL_NULL(&null_zv);
slot = zend_ast_create_zval(&null_zv);
@@ -506,7 +492,6 @@ static zend_ast *zend_ast_create_markup_component(zend_ast *name, zend_ast *attr
args = zend_ast_list_add(args, component_name);
args = zend_ast_list_add(args, attrs);
args = zend_ast_list_add(args, slot);
- args = zend_ast_list_add(args, named_slots);
args = zend_ast_list_add(args, function_name);
return zend_ast_create(ZEND_AST_CALL, fn, args);
@@ -532,27 +517,6 @@ zend_ast *zend_ast_create_markup_element(zend_ast *name, zend_ast *attrs, zend_a
return result;
}
- /* blocks lower to keyed children and are only routed by the
- * component builder above; in a plain element or fragment one would slip
- * into the children array as a stray string key. */
- zend_ast_list *clist = zend_ast_get_list(children);
- for (uint32_t i = 0; i < clist->children; i++) {
- zend_ast *elem = clist->child[i];
- if (elem != NULL && elem->kind == ZEND_AST_ARRAY_ELEM && elem->child[1] != NULL) {
- zend_throw_exception_ex(zend_ce_compile_error, 0,
- "Markup slot is only allowed in a component body",
- Z_STRVAL_P(zend_ast_get_zval(elem->child[1])));
- if (name != NULL) {
- zend_ast_destroy(name);
- }
- if (attrs != NULL) {
- zend_ast_destroy(attrs);
- }
- zend_ast_destroy(children);
- return NULL;
- }
- }
-
children->attr = ZEND_ARRAY_SYNTAX_SHORT;
args = zend_ast_create_list(0, ZEND_AST_ARG_LIST);
@@ -619,19 +583,17 @@ zend_ast *zend_ast_create_markup_checked(zend_ast *open, zend_ast *attrs, zend_a
}
/* Lower a dynamic tag `<$tag …>…$tag>` (RFC §4) into a call to
- * \Html\render_dynamic($tag, attributes, children, namedSlots). The variable's
- * runtime value decides what a static tag name decides at compile time, by the
- * same classification rule (zend_markup_name_is_component): a component name
- * dispatches through render_component's machinery (children become the
- * anonymous-slot Fragment, hooks and decorators included), anything else
- * constructs a literal \Html\Element. Only classification moves to runtime;
- * the body is still partitioned into loose children vs blocks
- * here, at compile time. `name` is the T_VARIABLE zval AST (the name without
- * the "$"). */
-/* Build the \Html\render_dynamic(tag_expr, attributes, children, namedSlots)
- * call both dynamic-tag forms lower into. `tag_expr` is an already-built
- * expression AST producing the tag name; `lineno` is the opening-tag line
- * (see zend_ast_create_markup_element for why it is stamped explicitly). */
+ * \Html\render_dynamic($tag, attributes, children). The variable's runtime
+ * value decides what a static tag name decides at compile time, by the same
+ * classification rule (zend_markup_name_is_component): a component name
+ * dispatches through render_component's machinery (children become the slot
+ * Fragment, hooks and decorators included), anything else constructs a literal
+ * \Html\Element. Only classification moves to runtime. `name` is the T_VARIABLE
+ * zval AST (the name without the "$"). */
+/* Build the \Html\render_dynamic(tag_expr, attributes, children) call both
+ * dynamic-tag forms lower into. `tag_expr` is an already-built expression AST
+ * producing the tag name; `lineno` is the opening-tag line (see
+ * zend_ast_create_markup_element for why it is stamped explicitly). */
static zend_ast *zend_markup_dynamic_call(zend_ast *tag_expr, zend_ast *attrs, zend_ast *children, uint32_t lineno)
{
if (attrs == NULL) {
@@ -639,16 +601,17 @@ static zend_ast *zend_markup_dynamic_call(zend_ast *tag_expr, zend_ast *attrs, z
}
attrs->attr = ZEND_ARRAY_SYNTAX_SHORT;
- zend_ast *anon, *named_slots;
- zend_markup_partition_children(children, &anon, &named_slots);
+ if (children == NULL) {
+ children = zend_ast_create_list(0, ZEND_AST_ARRAY);
+ }
+ children->attr = ZEND_ARRAY_SYNTAX_SHORT;
zend_ast *fn = zend_ast_markup_fq_name(ZEND_STRL(ZEND_MARKUP_DYNAMIC_FQ));
zend_ast *args = zend_ast_create_list(0, ZEND_AST_ARG_LIST);
args = zend_ast_list_add(args, tag_expr);
args = zend_ast_list_add(args, attrs);
- args = zend_ast_list_add(args, anon);
- args = zend_ast_list_add(args, named_slots);
+ args = zend_ast_list_add(args, children);
zend_ast *result = zend_ast_create(ZEND_AST_CALL, fn, args);
result->lineno = lineno;
diff --git a/Zend/zend_markup.h b/Zend/zend_markup.h
index 9a1d6142ec54..e45b44ea06ce 100644
--- a/Zend/zend_markup.h
+++ b/Zend/zend_markup.h
@@ -31,11 +31,12 @@
* runtime. Keep in sync with ext/html/html.stub.php, which declares the classes
* and functions these names refer to. */
-#define ZEND_MARKUP_ELEMENT_FQ "Html\\Element"
-#define ZEND_MARKUP_FRAGMENT_FQ "Html\\Fragment"
-#define ZEND_MARKUP_RAW_FQ "Html\\raw"
-#define ZEND_MARKUP_COMPONENT_FQ "Html\\render_component"
-#define ZEND_MARKUP_DYNAMIC_FQ "Html\\render_dynamic"
+#define ZEND_MARKUP_ELEMENT_FQ "Html\\Element"
+#define ZEND_MARKUP_FRAGMENT_FQ "Html\\Fragment"
+#define ZEND_MARKUP_LAZY_FRAGMENT_FQ "Html\\LazyFragment"
+#define ZEND_MARKUP_RAW_FQ "Html\\raw"
+#define ZEND_MARKUP_COMPONENT_FQ "Html\\render_component"
+#define ZEND_MARKUP_DYNAMIC_FQ "Html\\render_dynamic"
BEGIN_EXTERN_C()
@@ -54,9 +55,8 @@ zend_string *zend_markup_decode_entities(zend_string *str);
/* AST lowering, called by the markup grammar actions (zend_language_parser.y).
* Each returns NULL with a CompileError exception pending on invalid markup
- * (mismatched closing tag, misplaced slot); the grammar action then YYERRORs. */
+ * (e.g. a mismatched closing tag); the grammar action then YYERRORs. */
zend_ast *zend_ast_create_markup_raw(zend_ast *str);
-zend_ast *zend_ast_create_markup_slot(zend_ast *open, zend_ast *attrs, zend_ast *children, zend_ast *close);
zend_ast *zend_ast_create_markup_element(zend_ast *name, zend_ast *attrs, zend_ast *children);
zend_ast *zend_ast_create_markup_checked(zend_ast *open, zend_ast *attrs, zend_ast *children, zend_ast *close);
zend_ast *zend_ast_create_markup_dynamic(zend_ast *name, zend_ast *attrs, zend_ast *children);
diff --git a/ext/html/RFC.md b/ext/html/RFC.md
index b672376a4b5e..e2ededf6149c 100644
--- a/ext/html/RFC.md
+++ b/ext/html/RFC.md
@@ -62,7 +62,7 @@ Practically, a *syntax* lives or dies by its ecosystem. Code using an extension-
The goal of this RFC is a native, ergonomic syntax for building HTML - the output format the overwhelming majority of PHP produces - with escaping and composition built in.
-This RFC is deliberately narrower than XHP: it targets **HTML specifically**, not general XML. That choice is visible throughout - escaping uses `htmlspecialchars`; void elements serialize as HTML5 (` `, not ` `); text and attribute handling follow HTML, not XML, semantics. There is no XML namespace support, no CDATA, no DTD, and no requirement that output be well-formed XML (`` *looks* like a namespaced XML element but is a compile-time construct, not a namespace). This keeps the surface small and the output aimed at the one thing PHP overwhelmingly produces: web pages. Emitting XML (RSS, SVG, SOAP) remains the job of the existing DOM / `XMLWriter` APIs and is out of scope.
+This RFC is deliberately narrower than XHP: it targets **HTML specifically**, not general XML. That choice is visible throughout - escaping uses `htmlspecialchars`; void elements serialize as HTML5 (` `, not ` `); text and attribute handling follow HTML, not XML, semantics. There is no XML namespace support, no CDATA, no DTD, and no requirement that output be well-formed XML. This keeps the surface small and the output aimed at the one thing PHP overwhelmingly produces: web pages. Emitting XML (RSS, SVG, SOAP) remains the job of the existing DOM / `XMLWriter` APIs and is out of scope.
Semantic validation of attribute *values* is also not a goal - a `javascript:` URL in `href`/`src` escapes cleanly yet is not blocked: sometimes it is intentional, and the policy is the application's to set. (Latte nullifies such URLs and React warns; a framework can enforce the same here via a component decorator or userland wrapper.) Attribute and tag *names*, by contrast, are always validated - see Escape-by-default.
@@ -94,24 +94,23 @@ Every construct in this proposal lowers the same way - at compile time, to a `ne
// Components dispatch through Html\render_component() (see Tags)
-// → \Html\render_component(Card::class, ['title' => 'Hi'], null, [], 'Card')
+// → \Html\render_component(Card::class, ['title' => 'Hi'], null, 'Card')
-// Component body → anonymous slot; blocks → named slots (see Children & slots)
-
-
Title
-
Loose content.
+// Component body → slot; extra regions are markup-valued props (see Children & slots)
+Title}>
+
Body content.
// → \Html\render_component(
// Layout::class, // class-name candidate
-// ['theme' => 'dark'], // props → named arguments
-// new \Html\Fragment([/*
...
*/]), // anonymous slot
-// ['header' => new \Html\Fragment([/*
...
*/])], // named slots
+// ['theme' => 'dark', // props → named arguments
+// 'header' => new \Html\Element('h1', [], ['Title'])],
+// new \Html\Fragment([/*
...
*/]), // body slot
// 'Layout', // function-name candidate
// )
// Dynamic tags: the value classifies at runtime (see Tags)
<$tag class="box">{$content}$tag>
-// → \Html\render_dynamic($tag, ['class' => 'box'], [$content], [])
+// → \Html\render_dynamic($tag, ['class' => 'box'], [$content])
```
Two properties fall directly out of this lowering. Markup is **zero-cost sugar** - writing the objects by hand produces the same AST and opcodes. And markup obeys **ordinary expression semantics** - attributes and children are argument expressions, evaluated eagerly, scoped and typed like any other PHP code.
@@ -299,13 +298,13 @@ final class UserCard implements Html\Htmlable {
| `string` / `int` / `float` / `Stringable` | `="escaped value"` |
| `array` / other | `TypeError` |
-### 5. Children, slots, and named slots
+### 5. Children and slots
#### HTML elements
Children are an ordered list of child nodes (text, interpolations, sub-elements).
-#### Components - slots via `#[Html\Slot]`
+#### Components - the body slot via `#[Html\Slot]`
The body of a component is routed to a constructor/function/method parameter marked with the `#[Html\Slot]` attribute.
@@ -325,38 +324,69 @@ function Time(
The slot value is always a single `Html\Fragment` (an `Html\Htmlable` whose children are each normalized to `Html\Htmlable`); `{$slot}` renders the whole body, correctly escaped. The fragment's children are introspectable as an opt-in (`$slot->children`).
-#### Named slots - ``
+Rules for the slot parameter:
-`#[Html\Slot]` takes an optional name. Bare `#[Html\Slot]` is the **default/anonymous** slot (receiving loose body content); `#[Html\Slot('footer')]` is a **named** slot, filled by a `...` block.
+* **At most one** parameter may carry `#[Html\Slot]`; more than one is an **error**.
+* Body content given to a component with no `#[Html\Slot]` parameter, or a parameter filled both by an attribute and by body content, is an **error**.
+* `#[Html\Slot]` on a variadic parameter → **error** (a slot is a single value).
+* The slot is an ordinary parameter, so a **required** slot (no default) left unfilled fails at runtime like any missing argument (`ArgumentCountError`). Declare a default (`?Html\Htmlable $slot = null`) to make it optional.
+
+#### Further content areas are markup-valued props
+
+A component has exactly one body slot; any *additional* content regions are passed as ordinary props typed `Html\Htmlable`. Because an attribute value is an arbitrary expression and markup is a value, a chunk of markup passes straight through with no extra syntax:
```php
function Layout(
- #[Html\Slot] Html\Htmlable $body,
- #[Html\Slot('header')] ?Html\Htmlable $header = null,
- #[Html\Slot('footer')] ?Html\Htmlable $footer = null,
+ #[Html\Slot] Html\Htmlable $body,
+ ?Html\Htmlable $header = null,
+ ?Html\Htmlable $footer = null,
): Html\Htmlable { /* ... */ }
-
-
```
-This is also this proposal's answer to **template inheritance**: what Twig, Latte and Blade's older syntax call a base template with overridable *blocks* is here a `Layout` component with named slots - a page fills the slots instead of extending the template, and layouts nest by composition.
+A "named slot" is therefore just a prop, checked against the component's real signature exactly like any other - a misspelled `heaer=` is the same `TypeError` a misspelled prop is, not a silent no-op. This keeps the whole surface consistent with the RFC's headline property that props are type-checkable named arguments.
-Rules for slots are as follows:
+This is also the proposal's answer to **template inheritance**: what Twig, Latte and Blade's older syntax call a base template with overridable *blocks* is here a `Layout` component whose regions are markup-valued props (and the body slot) filled by composition, rather than a template that is extended; layouts nest the same way. (A dedicated `` block form for large named regions is a possible future ergonomic addition - see Future Scope - but expresses nothing a prop cannot.)
-* Multiple `#[Html\Slot]` parameters are allowed only with **distinct names** and **at most one anonymous** slot; otherwise an **error**.
-* A `` block with no matching `#[Html\Slot('foo')]` parameter → **error**.
-* Filling the same parameter both by attribute and by `` block → **error**.
-* A `` block is only allowed **directly in a component body** (not inside a plain element or fragment) and takes **no attributes** → **compile error** otherwise.
-* `#[Html\Slot]` on a variadic parameter → **error** (a slot is a single value).
-* Slots are ordinary parameters, so a **required** slot parameter (no default) left unfilled fails at runtime like any missing argument (`ArgumentCountError`). Declare a default (`?Html\Htmlable $footer = null`) to make a slot optional.
+#### Eager evaluation, and opting into laziness with `:lazy`
+
+Because a component invocation is an ordinary call, **the body is fully evaluated before the component runs** (PHP evaluates arguments before the call). A component therefore cannot, by default, conditionally skip or defer rendering its body - the body has already been built by the time the component decides what to do with it.
+
+The `:lazy` directive on a component tag opts its body out of that rule:
+
+```php
+function Auth(bool $check, #[Html\Slot] ?Html\Htmlable $slot = null): Html\Htmlable
+{
+ // When logged out we never reference $slot, so with :lazy the body's
+ // expressions never run.
+ return $check ?
{$slot}
: <>>;
+}
+
+// Eager: auth()->user()->name is evaluated before Auth runs - and would error
+// when logged out - even though Auth discards the body in that case.
+check()}>Hello, {auth()->user()->name}
-#### Eager evaluation
+// Lazy: the body is built, and its interpolations run, only if Auth renders it.
+check()}>Hello, {auth()->user()->name}
+```
-Because a component invocation is an ordinary call, **children are fully evaluated before the parent runs** (PHP evaluates arguments before the call). Consequently a component **cannot conditionally skip or defer rendering its children** out-of-the-box - regular control flow or closures can be used instead. Lazy children (via closures) are Future Scope.
+With `:lazy` the body lowers to an `Html\LazyFragment` wrapping a closure, instead of an eager `Html\Fragment`:
+
+```php
+// Hello, {$name}
+// → \Html\render_component(Auth::class, [],
+// new \Html\LazyFragment(fn() => new \Html\Fragment(['Hello, ', $name])), 'Auth')
+```
+
+`Html\LazyFragment` is *itself* an `Html\Htmlable`, so **the component's slot parameter type is unchanged** (`?Html\Htmlable`) and a component need not be written any differently to accept a lazy body - laziness is transparent to it. It evaluates its closure on first render and **memoizes** the result, so a body rendered more than once still runs its expressions once. Variables the body references are captured by value at the point of the tag (ordinary arrow-function semantics); only the *expressions* - method calls, property reads - are deferred to render time. A body that is never rendered is never evaluated.
+
+`:lazy` is deliberately the single point where markup departs from "an ordinary call with eager arguments," which is why it is explicit and opt-in; eager remains the default. (For a body that should be evaluated *repeatedly* rather than merely deferred, pass a closure as an ordinary prop and call it - e.g. `each={fn($x) =>
{$x}
}`.)
#### Child coercion
@@ -409,7 +439,8 @@ All under the `Html\` namespace:
* Interface `Html\Htmlable extends Stringable` - the renderable contract. The one method a class writes is `toHtml(): Htmlable`, which returns markup (ultimately an `Element`/`Fragment`/`Raw`). The inherited `__toString(): string` requirement is satisfied automatically: a userland class that does not declare (or inherit) a `__toString` of its own receives a default implementation at class-link time - it serializes what `toHtml()` produces - exactly the way every enum receives `cases()`. So `echo`/`(string)` work on any markup value or component, while `strict_types` components can `return
...
;` directly and callers can still reach the object tree via `toHtml()`. A declared `__toString` wins for string casts; markup rendering always goes through `toHtml()`.
* Classes `Html\Element`, `Html\Fragment` (and `Html\Raw`, the opaque passthrough that backs `raw()`/`escape()`). All are `final`, immutable value objects with `readonly` properties (`$tag`, `$attributes`, `$children` / `$html`), and each has a `toDom(?Dom\Document = null): Dom\DocumentFragment` method.
-* Attribute `Html\Slot` (`#[Html\Slot(?string $name = null)]`).
+* Class `Html\LazyFragment` - a `final` `Html\Htmlable` wrapping a `Closure` thunk (`__construct(Closure $thunk)`). The `:lazy` directive lowers a component body into one; it evaluates the thunk on first render and memoizes the result (see Children & slots). Not typically written by hand.
+* Attribute `Html\Slot` (bare `#[Html\Slot]`, no arguments) marking the parameter that receives a component's body.
* Functions `Html\raw(string $html): Html\Htmlable`, `Html\escape(string $text): Html\Htmlable`.
* Function `Html\render_component()` - the runtime target a component tag lowers into (name resolution candidates, props, slots). It is a public function so the dispatch rules are testable and so generated code is honest, but user code is expected to write tags, not call it.
* Function `Html\render_dynamic()` - the runtime target a dynamic tag `<$tag>` lowers into: classifies the value by the element-vs-component rule and either constructs an `Html\Element` or dispatches through the `render_component()` machinery. Public for the same reasons.
@@ -417,7 +448,7 @@ All under the `Html\` namespace:
### Error handling
-Compile-time violations (mismatched closing tag, misplaced `` block, a stray `<` in markup text) throw the standard **`CompileError`** / **`ParseError`**, catchable exactly like any other PHP syntax error. At runtime, unsupported attribute/child value types throw **`TypeError`**; invalid dynamically-supplied tag/attribute names throw **`ValueError`**; dispatch failures (unknown component, non-`Htmlable` return, misconfigured slots) throw **`Error`**.
+Compile-time violations (mismatched closing tag, a stray `<` in markup text) throw the standard **`CompileError`** / **`ParseError`**, catchable exactly like any other PHP syntax error. At runtime, unsupported attribute/child value types throw **`TypeError`**; invalid dynamically-supplied tag/attribute names throw **`ValueError`**; dispatch failures (unknown component, non-`Htmlable` return, a misconfigured slot) throw **`Error`**.
## Examples
@@ -430,12 +461,10 @@ A markup expression spans as many lines as it needs. It is an ordinary expressio
```php
function ProductPage(Product $product, User $viewer): Html\Htmlable
{
- return name}>
-
+ return name} header={<>
{$product->name}
{$product->onSale ? Sale : null}
-
-
+ >}>
{$product->description}
{array_map(fn($feature) =>
{$feature}
, $product->features)}
@@ -540,7 +569,7 @@ Because `<` followed by a letter, `$`, `{`, or `>` in operand position is *alway
This leaves just one backwards compatibility concern:
-1. **New `Html\` namespace** - introduces `Html\Htmlable`, `Html\Element`, `Html\Fragment`, `Html\Raw`, `Html\Slot`, `Html\raw()`, `Html\escape()`. Top-level `Html\` follows the established precedent for bundled extensions (`Random\`, `Dom\`, `Uri\`), and real-world top-level `Html\` packages are rare. No existing keywords are reserved and no new global reserved words are introduced (the syntax uses bare `<` and a `slot:` form valid only inside markup).
+1. **New `Html\` namespace** - introduces `Html\Htmlable`, `Html\Element`, `Html\Fragment`, `Html\Raw`, `Html\Slot`, `Html\raw()`, `Html\escape()`. Top-level `Html\` follows the established precedent for bundled extensions (`Random\`, `Dom\`, `Uri\`), and real-world top-level `Html\` packages are rare. No existing keywords are reserved and no new global reserved words are introduced (the syntax uses bare `<`, and the `:lazy` directive is only meaningful inside a markup tag).
### Impact on tooling
@@ -561,8 +590,8 @@ Some natural extensions are deliberately left out of this RFC to keep its scope
* **Context-aware escaping** (URL/JS/CSS contexts), as in Go's `html/template` or Latte's escaping, - so that e.g. a `javascript:` URL in `href` is caught. This RFC escapes HTML context only; the value-tree model is designed so this can be layered on later without a syntax change. Some examples include:
* A JavaScript escaping context inside `';
@@ -98,7 +85,6 @@ bool(true)
;', '$x = <{"div"}>a$t>;'] as $code) {
}
}
-// A block against an element-classified value has nowhere to go.
-$tag = 'div';
-try {
- $x = <$tag>x$tag>;
-} catch (Error $e) {
- echo $e->getMessage(), "\n";
-}
-
// An empty tag value is rejected up front.
try {
Html\render_dynamic('');
@@ -70,7 +62,6 @@ try {
Mismatched markup closing tag: expected $a>, found $b>
syntax error, unexpected markup tag name "div", expecting markup tag end
syntax error, unexpected variable "$t", expecting markup tag end
-Markup slot blocks are only allowed in a component body; "div" is an HTML element
Html\render_dynamic(): Argument #1 ($tag) cannot be empty
Invalid tag name "di v"
diff --git a/ext/html/tests/syntax_named_slots.phpt b/ext/html/tests/syntax_named_slots.phpt
deleted file mode 100644
index f95350ed6146..000000000000
--- a/ext/html/tests/syntax_named_slots.phpt
+++ /dev/null
@@ -1,46 +0,0 @@
---TEST--
-Markup syntax: blocks route to #[Html\Slot('name')] parameters (RFC §6)
---EXTENSIONS--
-html
---FILE--
- $lang], [
- new E('header', [], [$header]),
- new E('article', [], [$body]),
- new E('footer', [], [$footer]),
- ]);
-}
-
-$title = 'Hello & welcome';
-
-// anonymous + named slots together
-echo (
', $rows);
-};
-
-echo "Equivalence check:\n";
-printf(" markup === objects : %s\n", $markup() === $objects() ? 'yes' : 'NO');
-printf(" markup === concat : %s\n", $markup() === $concat() ? 'yes' : 'NO');
-printf(" markup === sprintf : %s\n", $markup() === $printf() ? 'yes' : 'NO');
-
-echo "\nThroughput ($ITER iterations, 10 items each):\n";
-bench('markup syntax', $markup, $ITER);
-bench('plain Html objects', $objects, $ITER);
-bench('string concat', $concat, $ITER);
-bench('sprintf', $printf, $ITER);
diff --git a/ext/html/benchmarks/bench_scan.php b/ext/html/benchmarks/bench_scan.php
deleted file mode 100644
index ac5d60a4068d..000000000000
--- a/ext/html/benchmarks/bench_scan.php
+++ /dev/null
@@ -1,32 +0,0 @@
-" and "
Date: Thu, 9 Jul 2026 22:34:03 +0100
Subject: [PATCH 06/17] Remove bare function resolution
---
Zend/zend_ast.c | 10 -
Zend/zend_ast.h | 3 -
Zend/zend_compile.c | 37 ---
Zend/zend_markup.c | 60 ++---
ext/html/RFC.md | 166 ++++++-------
ext/html/html.c | 220 +++++-------------
ext/html/html.stub.php | 75 ++----
ext/html/html_arginfo.h | 17 +-
ext/html/php_html.h | 22 +-
ext/html/tests/component_decorators.phpt | 11 +-
ext/html/tests/component_di_hooks.phpt | 22 +-
ext/html/tests/component_dispatch.phpt | 36 ---
.../tests/component_resolution_errors.phpt | 46 +++-
ext/html/tests/component_scoped_hooks.phpt | 39 +---
ext/html/tests/component_slot_errors.phpt | 29 ++-
ext/html/tests/component_slots.phpt | 21 +-
ext/html/tests/dispatch_hardening.phpt | 2 -
ext/html/tests/lazy_fragment.phpt | 14 +-
ext/html/tests/syntax_attr_prefix.phpt | 8 +-
.../tests/syntax_component_hyphenated.phpt | 13 +-
.../syntax_component_namespace_fallback.phpt | 40 ----
.../tests/syntax_component_qualified.phpt | 41 ++--
ext/html/tests/syntax_components.phpt | 19 +-
ext/html/tests/syntax_dynamic.phpt | 15 +-
ext/html/tests/syntax_dynamic_errors.phpt | 16 +-
ext/html/tests/syntax_self_close_output.phpt | 4 +-
ext/html/tests/syntax_spread.phpt | 8 +-
ext/html/tests/transpile.phpt | 11 +-
28 files changed, 372 insertions(+), 633 deletions(-)
delete mode 100644 ext/html/tests/component_dispatch.phpt
delete mode 100644 ext/html/tests/syntax_component_namespace_fallback.phpt
diff --git a/Zend/zend_ast.c b/Zend/zend_ast.c
index aef7325f3d10..d3ce419c737e 100644
--- a/Zend/zend_ast.c
+++ b/Zend/zend_ast.c
@@ -2574,16 +2574,6 @@ static ZEND_COLD void zend_ast_export_ex(smart_str *str, zend_ast *ast, int prio
}
smart_str_appends(str, "::class");
break;
- case ZEND_AST_CALLABLE_NAME:
- /* Only produced by markup component lowering, which is a call and so
- * never reaches a const-expr export; handled for completeness. It
- * compiles to a constant function name (or candidate-name list, see
- * zend_compile_callable_name), so export the as-written name quoted
- * rather than as a bare constant. */
- smart_str_appendc(str, '\'');
- zend_ast_export_ns_name(str, ast->child[0], 0, indent);
- smart_str_appendc(str, '\'');
- break;
case ZEND_AST_ASSIGN: BINARY_OP(" = ", 90, 91, 90);
case ZEND_AST_ASSIGN_REF: BINARY_OP(" =& ", 90, 91, 90);
case ZEND_AST_ASSIGN_OP:
diff --git a/Zend/zend_ast.h b/Zend/zend_ast.h
index aa59f1e2b720..24b77d7d3493 100644
--- a/Zend/zend_ast.h
+++ b/Zend/zend_ast.h
@@ -111,9 +111,6 @@ enum _zend_ast_kind {
ZEND_AST_BREAK,
ZEND_AST_CONTINUE,
ZEND_AST_PROPERTY_HOOK_SHORT_BODY,
- /* Native markup: declared last in its group so it appends to the enum
- * instead of renumbering the kinds after it (which would break ext/php-ast). */
- ZEND_AST_CALLABLE_NAME,
/* 2 child nodes */
ZEND_AST_DIM = 2 << ZEND_AST_NUM_CHILDREN_SHIFT,
diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c
index aa1cf4f4c67e..9f8ebad8ab29 100644
--- a/Zend/zend_compile.c
+++ b/Zend/zend_compile.c
@@ -11425,40 +11425,6 @@ static void zend_compile_class_name(znode *result, const zend_ast *ast) /* {{{ *
}
/* }}} */
-/* Native markup (RFC: Native Markup Expressions). Resolve a component tag's name
- * against the *function* import table (`use function`) and current namespace,
- * yielding a compile-time constant: a single name, or a candidate list (current
- * namespace, then global) when an unqualified name in a namespace needs PHP's
- * usual global fallback. This is the function-table analogue of
- * ZEND_AST_CLASS_NAME: a `` tag is lowered to two candidate names — a
- * class one (ZEND_AST_CLASS_NAME) and a function one (this) — so the runtime's
- * class-then-function dispatch can honor `use` and `use function` independently,
- * exactly as PHP resolves class vs call names. See zend_ast_create_markup_component
- * in Zend/zend_markup.c. */
-static void zend_compile_callable_name(znode *result, const zend_ast *ast) /* {{{ */
-{
- zend_ast *name_ast = ast->child[0];
- zend_string *name = zend_ast_get_str(name_ast);
- bool is_fully_qualified;
- zend_string *resolved =
- zend_resolve_function_name(name, name_ast->attr, &is_fully_qualified);
-
- result->op_type = IS_CONST;
- if (!is_fully_qualified && !zend_string_equals(resolved, name)) {
- /* An unqualified name inside a namespace resolves like an ordinary
- * call: current namespace first, then the global namespace. Pass both
- * candidates, in that order, for the runtime to try. */
- zval candidates;
- array_init_size(&candidates, 2);
- add_next_index_str(&candidates, resolved);
- add_next_index_str(&candidates, zend_string_copy(name));
- ZVAL_COPY_VALUE(&result->u.constant, &candidates);
- } else {
- ZVAL_STR(&result->u.constant, resolved);
- }
-}
-/* }}} */
-
static zend_op *zend_compile_rope_add_ex(zend_op *opline, znode *result, uint32_t num, znode *elem_node) /* {{{ */
{
if (num == 0) {
@@ -12257,9 +12223,6 @@ static void zend_compile_expr_inner(znode *result, zend_ast *ast) /* {{{ */
case ZEND_AST_CLASS_NAME:
zend_compile_class_name(result, ast);
return;
- case ZEND_AST_CALLABLE_NAME:
- zend_compile_callable_name(result, ast);
- return;
case ZEND_AST_ENCAPS_LIST:
zend_compile_encaps_list(result, ast);
return;
diff --git a/Zend/zend_markup.c b/Zend/zend_markup.c
index 653cb3961b30..e0eb142ae98c 100644
--- a/Zend/zend_markup.c
+++ b/Zend/zend_markup.c
@@ -315,11 +315,11 @@ static zend_ast *zend_ast_markup_fq_name(const char *name, size_t len)
}
/* Element vs component classification (RFC §4): a tag whose first character is
- * uppercase, which is namespace-qualified (contains "\"), or which names a static
- * method via "::", dispatches as a component; anything else is a literal HTML
- * element. The single home for this rule: the compiler applies it to static tag
- * names here, and Html\render_dynamic applies it to a dynamic tag's runtime
- * value (hence ZEND_API). */
+ * uppercase, which is namespace-qualified (contains "\"), or which names a
+ * static method via "::", dispatches as a component; anything else is a literal
+ * HTML element. The single home for this rule: the compiler applies it to
+ * static tag names here, and Html\render_dynamic applies it to a dynamic tag's
+ * runtime value (hence ZEND_API). */
ZEND_API bool zend_markup_name_is_component(zend_string *tag)
{
return (ZSTR_LEN(tag) > 0 && ZSTR_VAL(tag)[0] >= 'A' && ZSTR_VAL(tag)[0] <= 'Z')
@@ -401,24 +401,26 @@ static bool zend_markup_extract_lazy(zend_ast *attrs)
return false;
}
-/* Lower a component tag (capitalized name, namespace-qualified name, or
- * "Class::method") into a call to
- * \Html\render_component(component, props, slot, functionComponent)
+/* Lower a component tag (a capitalized or namespace-qualified name, or
+ * "Class::method") into a call to \Html\render_component(component, props, slot)
* (RFC §3). Attributes become the props array and the body content becomes a
* single \Html\Fragment passed as the slot (or a lazy \Html\LazyFragment when
* the `:lazy` directive is present).
*
- * Component resolution is two-stage. A bare tag could name either a class
- * component or a function component, which PHP resolves through *separate* import
- * tables (`use` vs `use function`). So this pass resolves the name *both* ways —
- * a class candidate (ZEND_AST_CLASS_NAME, the `component` argument) and a function
- * candidate (ZEND_AST_CALLABLE_NAME, the `functionComponent` argument) — and the
- * runtime applies the dispatch order (Class::method, then Htmlable class, then
- * userland function) across the two, so `use App\C;` resolves a class and
- * `use function App\C;` resolves a function, exactly as ordinary PHP does. A
- * leading "\" makes both candidates fully qualified. A "Class::method" tag is
- * unambiguously a static method, so it has no function candidate. See
- * PHP_FUNCTION(Html_render_component) in ext/html/html.c for the second stage. */
+ * Component resolution is two-stage, and every tag resolves through the *class*
+ * name rules only: a bare/qualified tag lowers to ZEND_AST_CLASS_NAME (the
+ * `Component::class` machinery), and a "Class::method" tag lowers the class
+ * part the same way with "::method" appended — both honor `use` imports and
+ * the current namespace, with a leading "\" fully qualified. The runtime then
+ * resolves the name to a class implementing Html\Htmlable (instantiated) or a
+ * public static method (called) — see PHP_FUNCTION(Html_render_component) in
+ * ext/html/html.c.
+ *
+ * Plain *function* components are deliberately out of v1: a bare tag gives no
+ * signal whether a class or a function is meant, and functions resolve through
+ * the separate `use function` import table, so supporting them would need a
+ * second compile-time resolution and a new AST kind to carry it. A static
+ * method has no such ambiguity — its class part resolves like any class. */
static zend_ast *zend_ast_create_markup_component(zend_ast *name, zend_ast *attrs, zend_ast *children)
{
uint32_t lineno = zend_ast_get_lineno(name);
@@ -426,14 +428,12 @@ static zend_ast *zend_ast_create_markup_component(zend_ast *name, zend_ast *attr
const char *sep = strstr(ZSTR_VAL(tag), "::");
/* A leading "\" is a fully-qualified reference; otherwise resolve the name
* (unqualified or qualified) against the current use/namespace. */
- uint32_t name_type = (ZSTR_LEN(tag) > 0 && ZSTR_VAL(tag)[0] == '\\')
- ? ZEND_NAME_FQ : ZEND_NAME_NOT_FQ;
+ uint32_t name_type = (ZSTR_VAL(tag)[0] == '\\') ? ZEND_NAME_FQ : ZEND_NAME_NOT_FQ;
zend_ast *component_name;
- zend_ast *function_name;
if (sep != NULL) {
/* "Class::method": resolve the class part as a class, then append
- * "::method". A static method is never a plain function. */
+ * "::method". */
size_t class_len = sep - ZSTR_VAL(tag);
zend_ast *class_name = zend_ast_create_zval_from_str(
zend_string_init(ZSTR_VAL(tag), class_len, 0));
@@ -444,20 +444,10 @@ static zend_ast *zend_ast_create_markup_component(zend_ast *name, zend_ast *attr
component_name = zend_ast_create_concat_op(
zend_ast_create(ZEND_AST_CLASS_NAME, class_name),
zend_ast_create_zval(&suffix));
-
- zval null_zv;
- ZVAL_NULL(&null_zv);
- function_name = zend_ast_create_zval(&null_zv);
} else {
- /* Resolve the name both as a class (honors `use`) and as a function
- * (honors `use function`); the runtime picks between them. */
zend_ast *class_name = zend_ast_create_zval_from_str(zend_string_copy(tag));
class_name->attr = name_type;
component_name = zend_ast_create(ZEND_AST_CLASS_NAME, class_name);
-
- zend_ast *func_name = zend_ast_create_zval_from_str(zend_string_copy(tag));
- func_name->attr = name_type;
- function_name = zend_ast_create(ZEND_AST_CALLABLE_NAME, func_name);
}
zend_ast_destroy(name);
@@ -492,7 +482,6 @@ static zend_ast *zend_ast_create_markup_component(zend_ast *name, zend_ast *attr
args = zend_ast_list_add(args, component_name);
args = zend_ast_list_add(args, attrs);
args = zend_ast_list_add(args, slot);
- args = zend_ast_list_add(args, function_name);
return zend_ast_create(ZEND_AST_CALL, fn, args);
}
@@ -510,7 +499,8 @@ zend_ast *zend_ast_create_markup_element(zend_ast *name, zend_ast *attrs, zend_a
uint32_t lineno = (name != NULL)
? zend_ast_get_lineno(name) : zend_ast_get_lineno(children);
- /* A capitalized name, or one containing "::", is a component (RFC §3). */
+ /* A capitalized name, a namespace-qualified name, or one containing "::"
+ * is a component (RFC §3). */
if (name != NULL && zend_markup_name_is_component(zend_ast_get_str(name))) {
result = zend_ast_create_markup_component(name, attrs, children);
result->lineno = lineno;
diff --git a/ext/html/RFC.md b/ext/html/RFC.md
index e2ededf6149c..f117cef77ec2 100644
--- a/ext/html/RFC.md
+++ b/ext/html/RFC.md
@@ -13,12 +13,17 @@ Yet PHP still has no native way to do it. Today authors choose between:
This RFC proposes a **native markup expression syntax**, inspired by JSX, that produces an in-memory tree of objects which renders to safely-escaped HTML. Markup becomes a *value*: it can be returned, passed to functions, stored, and composed.
```php
-function Greeting(string $name, string $type): Html\Htmlable
+class Greeting implements Html\Htmlable
{
- return <>
-
Hello, {$name ?: ucfirst($type)}!
-
Welcome to PHP, where markup is a first-class expression.
- >;
+ public function __construct(public string $name, public string $type) {}
+
+ public function toHtml(): Html\Htmlable
+ {
+ return <>
+
Hello, {$this->name ?: ucfirst($this->type)}!
+
Welcome to PHP, where markup is a first-class expression.
+ >;
+ }
}
echo ; // prints the rendered HTML, dynamic values escaped
@@ -94,18 +99,17 @@ Every construct in this proposal lowers the same way - at compile time, to a `ne
// Components dispatch through Html\render_component() (see Tags)
-// → \Html\render_component(Card::class, ['title' => 'Hi'], null, 'Card')
+// → \Html\render_component(Card::class, ['title' => 'Hi'])
// Component body → slot; extra regions are markup-valued props (see Children & slots)
Title}>
Body content.
// → \Html\render_component(
-// Layout::class, // class-name candidate
+// Layout::class, // resolved as a class name string
// ['theme' => 'dark', // props → named arguments
// 'header' => new \Html\Element('h1', [], ['Title'])],
// new \Html\Fragment([/*
...
*/]), // body slot
-// 'Layout', // function-name candidate
// )
// Dynamic tags: the value classifies at runtime (see Tags)
@@ -138,27 +142,16 @@ Dispatch is purely **syntactic** (the compiler cannot know runtime types):
| Tag form | Meaning |
|---|---|
| `
`, `` (lowercase / hyphenated) | literal HTML element → `Html\Element` |
-| `` (bare, capitalized) | a **component** - resolved as a name honoring `use` / `use function` / namespace |
-| ``, `<\App\Card>` (namespace-qualified) | a **component** - a namespace-qualified or fully-qualified name |
-| `` | a **component** - a static method call on a class |
+| `` (bare, capitalized) | a **component** - resolved as a class name honoring `use` / namespace |
+| ``, `<\App\Card>` (namespace-qualified) | a **component** - a namespace-qualified or fully-qualified class name |
+| `` | a **component** - a public static method on a class |
| `<$tag>`, `<{expr}>` (a variable / any expression) | a **dynamic tag** - the runtime string value is classified by the same rule, at runtime |
Any tag whose name is capitalized, contains a namespace separator (`\`), or names a static method (`::`) is a component; everything else is a literal HTML element.
-A **component is anything that produces `Html\Htmlable`.** There are three forms:
+A **component produces `Html\Htmlable`**, and every component tag resolves through the *class* name rules alone. There are two forms:
```php
-// Function
-function Time(DateTimeInterface $datetime): Html\Htmlable { /* ... */ }
-// → Time(datetime: $datetime)
-
-// Static method - multiple components can live together on one class
-class Author {
- public static function byline(string $name): Html\Htmlable { /* ... */ }
- public static function avatar(string $src): Html\Htmlable { /* ... */ }
-}
-// → Author::byline(name: 'Rasmus')
-
// Class implementing Html\Htmlable - the instance IS the renderable
class Card implements Html\Htmlable {
public function __construct(public string $title) {}
@@ -167,33 +160,31 @@ class Card implements Html\Htmlable {
}
}
// → new Card(title: 'Hi')
-```
-
-`toHtml()` returns markup, not a string, so it composes under `strict_types` with no cast, and callers can still reach the produced `Html\Element` tree. It may also return another `Htmlable` - e.g. delegate to another component instance.
-#### Bare-tag and qualified-tag resolution
-
-A component tag names a class or a function. PHP resolves class names and function names through **separate** rules and `use` tables, and a bare `` tag gives no syntactic signal which is meant, so the compiler resolves the name **both ways** and lets the runtime pick:
+// Static method - multiple small components can live together on one class
+class Author {
+ public static function byline(string $name): Html\Htmlable { /* ... */ }
+ public static function avatar(string $src): Html\Htmlable { /* ... */ }
+}
+// → Author::byline(name: 'Rasmus')
+```
-* as a **class** name - honoring `use` and the current namespace (as `Foo::class` does);
-* as a **function** name - honoring `use function` and the current namespace.
+`toHtml()` returns markup, not a string, so it composes under `strict_types` with no cast, and callers can still reach the produced `Html\Element` tree. It may also return another `Htmlable` - e.g. delegate to another component instance. A static-method component must likewise return `Html\Htmlable`, and must be public, static, and user-defined.
-The two candidates are then dispatched at runtime in this order:
+Both forms resolve through the class resolution every PHP developer already knows - a `` tag's *class part* resolves exactly as `Author::class` would. Plain *function* components are deferred to Future Scope: a bare `` gives no syntactic signal whether a class or a function is meant, and PHP resolves the two through separate `use` / `use function` import tables, so supporting functions means dual-candidate name resolution in the compiler and a class-vs-function dispatch order at runtime (plus guarding tags like `` from resolving to internal functions such as `date()`). A static method has no such ambiguity.
-1. the class candidate names a class implementing `Html\Htmlable` → instantiate it;
-2. else the function candidate names a userland function → call it (its result must be `Html\Htmlable`);
-3. else → a clear error.
+#### Bare-tag and qualified-tag resolution
-The consequence is that components follow ordinary PHP scoping exactly - a class component is imported with `use`, a function component with `use function`, and using the wrong keyword fails just as it would in a normal `new`/call:
+A component tag resolves exactly as a class name does - honoring `use` imports and the current namespace, precisely as `Foo::class` would where the tag appears. The same applies to the class part of a static-method tag:
```php
namespace Views;
-use App\Card; // a class component
-use function App\Greeting; // a function component
+use App\Card;
+use App\Author;
- // → new App\Card(title: 'Hi')
- // → App\greeting()
+ // → new App\Card(title: 'Hi')
+ // → App\Author::byline(name: 'R')
```
Tags may also be written **namespace-qualified** or **fully-qualified** directly; any tag containing `\` is a component regardless of the first letter's case:
@@ -228,19 +219,16 @@ Closing follows the form:
#### Userland integration: dispatch hooks
-By default the engine constructs a class component with `new` and calls a function or static-method component directly. In userland, particularly within frameworks, they will often want to intervene - to resolve components through a dependency-injection container (autowiring the constructor/parameter dependencies the props do not supply), or to apply a cross-cutting transform to every component's output. Three request-scoped hook lists make this possible without touching any component's code. Each behaves like `spl_autoload_register` - handlers are kept in registration order, with a matching `unregister_*` that reports whether a handler matched:
+By default the engine constructs a class component with `new` and calls a static-method component directly. In userland, particularly within frameworks, they will often want to intervene - to resolve components through a dependency-injection container (autowiring the constructor dependencies the props do not supply), or to apply a cross-cutting transform to every component's output. Two request-scoped hook lists make this possible without touching any component's code. Each behaves like `spl_autoload_register` - handlers are kept in registration order, with a matching `unregister_*` that reports whether a handler matched:
| Hook | Fires for | Signature | Purpose |
|---|---|---|---|
| `Html\register_component_factory` | class components | `(string $class, array $args): ?object` | replace `new` (e.g. container construction) |
-| `Html\register_component_invoker` | function & static-method components | `(callable $component, array $args): ?Html\Htmlable` | replace the call (e.g. parameter autowiring) |
| `Html\register_component_decorator` | **every** component kind | `(Html\Htmlable $rendered, string $component): Html\Htmlable` | transform / wrap / log / cache the output |
-The engine always does the part only it can: resolve the name, confirm the target, and route props + `#[Html\Slot]` content into `$args`. A **factory** or **invoker** only changes *how the value is produced* - it returns the value, or `null` to defer to the next handler (and finally to the engine's own `new`/call); a factory's result must be an instance of the requested class, an invoker's must be `Html\Htmlable`.
+The engine always does the part only it can: resolve the name, confirm the target, and route props + `#[Html\Slot]` content into `$args`. A **factory** only changes *how a class component's instance is produced* - it returns an instance of the requested class, or `null` to defer to the next handler (and finally to the engine's own `new`). A **decorator** is the cross-cutting seam: it runs on the `Html\Htmlable` every component produces - class and static-method alike - composing in registration order. (Plain HTML elements such as `
` are not components; no hook fires for them. A static-method component's *call* has no production hook; a component that needs container construction is a class component.)
-Construction and invocation are separate hooks because a container needs different information to autowire each (the class name vs. the callable). A **decorator** is the cross-cutting seam: it runs on the `Html\Htmlable` every component produces, composing in registration order. (Plain HTML elements such as `
` are not components; no hook fires for them.)
-
-Every `register_*`/`unregister_*` function also takes an optional second argument, `?string $component = null`, that scopes the hook to a single component: the hook only fires when the resolved name it would receive - the class FQCN, the function name, or `"Class::method"` - matches (case-insensitively, with or without a leading backslash). Scoped and unscoped hooks share one chain in registration order, and the dispatch skips out-of-scope hooks inside the engine, before any userland call, so a hook registered for one component costs the others nothing. Unregistering matches on both the callable and the scope it was registered with.
+Every `register_*`/`unregister_*` function also takes an optional second argument, `?string $component = null`, that scopes the hook to a single component: the hook only fires when the resolved name it would receive - the class FQCN, or `"Class::method"` - matches (case-insensitively, with or without a leading backslash). Scoped and unscoped hooks share one chain in registration order, and the dispatch skips out-of-scope hooks inside the engine, before any userland call, so a hook registered for one component costs the others nothing. Unregistering matches on both the callable and the scope it was registered with.
Registration is scoped to the current runtime, so a framework registers hooks during bootstrap; when nothing is registered the hooks add no cost - the default `new`/call path is unchanged.
@@ -251,9 +239,6 @@ $container = ...;
Html\register_component_factory(
fn(string $class, array $args) => $container->make($class, $args)
);
-Html\register_component_invoker(
- fn(callable $fn, array $args) => $container->call($fn, $args)
-);
// ...and a cross-cutting decorator (profiling, caching, wrapping, ...).
Html\register_component_decorator(function (Html\Htmlable $rendered, string $component) {
@@ -306,20 +291,23 @@ Children are an ordered list of child nodes (text, interpolations, sub-elements)
#### Components - the body slot via `#[Html\Slot]`
-The body of a component is routed to a constructor/function/method parameter marked with the `#[Html\Slot]` attribute.
+The body of a component is routed to the constructor parameter marked with the `#[Html\Slot]` attribute.
```php
-function Time(
- DateTimeInterface $datetime,
- #[Html\Slot] ?Html\Htmlable $slot = null,
-): Html\Htmlable {
- return ;
+class Time implements Html\Htmlable {
+ public function __construct(
+ public DateTimeInterface $datetime,
+ #[Html\Slot] public ?Html\Htmlable $slot = null,
+ ) {}
+ public function toHtml(): Html\Htmlable {
+ return ;
+ }
}
-// → Time(datetime: $d)
-// → Time(datetime: $d, slot: )
+// → new Time(datetime: $d)
+// → new Time(datetime: $d, slot: )
```
The slot value is always a single `Html\Fragment` (an `Html\Htmlable` whose children are each normalized to `Html\Htmlable`); `{$slot}` renders the whole body, correctly escaped. The fragment's children are introspectable as an opt-in (`$slot->children`).
@@ -336,11 +324,14 @@ Rules for the slot parameter:
A component has exactly one body slot; any *additional* content regions are passed as ordinary props typed `Html\Htmlable`. Because an attribute value is an arbitrary expression and markup is a value, a chunk of markup passes straight through with no extra syntax:
```php
-function Layout(
- #[Html\Slot] Html\Htmlable $body,
- ?Html\Htmlable $header = null,
- ?Html\Htmlable $footer = null,
-): Html\Htmlable { /* ... */ }
+class Layout implements Html\Htmlable {
+ public function __construct(
+ #[Html\Slot] public Html\Htmlable $body,
+ public ?Html\Htmlable $header = null,
+ public ?Html\Htmlable $footer = null,
+ ) {}
+ public function toHtml(): Html\Htmlable { /* ... */ }
+}
Title}
@@ -361,11 +352,18 @@ Because a component invocation is an ordinary call, **the body is fully evaluate
The `:lazy` directive on a component tag opts its body out of that rule:
```php
-function Auth(bool $check, #[Html\Slot] ?Html\Htmlable $slot = null): Html\Htmlable
+class Auth implements Html\Htmlable
{
- // When logged out we never reference $slot, so with :lazy the body's
- // expressions never run.
- return $check ?
{$slot}
: <>>;
+ public function __construct(
+ public bool $check,
+ #[Html\Slot] public ?Html\Htmlable $slot = null,
+ ) {}
+ public function toHtml(): Html\Htmlable
+ {
+ // When logged out we never reference $slot, so with :lazy the body's
+ // expressions never run.
+ return $this->check ?
{$this->slot}
: <>>;
+ }
}
// Eager: auth()->user()->name is evaluated before Auth runs - and would error
@@ -442,9 +440,9 @@ All under the `Html\` namespace:
* Class `Html\LazyFragment` - a `final` `Html\Htmlable` wrapping a `Closure` thunk (`__construct(Closure $thunk)`). The `:lazy` directive lowers a component body into one; it evaluates the thunk on first render and memoizes the result (see Children & slots). Not typically written by hand.
* Attribute `Html\Slot` (bare `#[Html\Slot]`, no arguments) marking the parameter that receives a component's body.
* Functions `Html\raw(string $html): Html\Htmlable`, `Html\escape(string $text): Html\Htmlable`.
-* Function `Html\render_component()` - the runtime target a component tag lowers into (name resolution candidates, props, slots). It is a public function so the dispatch rules are testable and so generated code is honest, but user code is expected to write tags, not call it.
+* Function `Html\render_component()` - the runtime target a component tag lowers into (resolved class name, props, slot). It is a public function so the dispatch rules are testable and so generated code is honest, but user code is expected to write tags, not call it.
* Function `Html\render_dynamic()` - the runtime target a dynamic tag `<$tag>` lowers into: classifies the value by the element-vs-component rule and either constructs an `Html\Element` or dispatches through the `render_component()` machinery. Public for the same reasons.
-* Dispatch hooks, each with a matching `unregister_*` returning `bool`: `Html\register_component_factory()`, `Html\register_component_invoker()`, `Html\register_component_decorator()`. Each takes an optional `?string $component = null` scoping the hook to one component. Registering a non-callable throws a `TypeError` immediately (as `spl_autoload_register()` does).
+* Dispatch hooks, each with a matching `unregister_*` returning `bool`: `Html\register_component_factory()`, `Html\register_component_decorator()`. Each takes an optional `?string $component = null` scoping the hook to one component. Registering a non-callable throws a `TypeError` immediately (as `spl_autoload_register()` does).
### Error handling
@@ -486,15 +484,22 @@ enum Variant: string
case Secondary = 'btn-secondary';
}
-function Button(Variant $variant, #[Html\Slot] Html\Htmlable $label): Html\Htmlable
+class Button implements Html\Htmlable
{
- return ;
+ public function __construct(
+ public Variant $variant,
+ #[Html\Slot] public Html\Htmlable $label,
+ ) {}
+ public function toHtml(): Html\Htmlable
+ {
+ return ;
+ }
}
-echo ;
+echo ;
//
```
@@ -522,12 +527,12 @@ echo
{'{'}example{'}'}
;
### Components are testable values
-A component call is an ordinary call and its result an ordinary value object, so a component is unit-tested directly: call it, assert against the tree - no rendering, no string matching, no DOM parsing:
+A component is an ordinary object and its `toHtml()` result an ordinary value object, so a component is unit-tested directly: construct it, assert against the tree - no rendering, no string matching, no DOM parsing:
```php
public function testSaleBadgeShownWhenOnSale(): void
{
- $el = ProductBadge(product: $this->saleProduct());
+ $el = (new ProductBadge(product: $this->saleProduct()))->toHtml();
$this->assertInstanceOf(Html\Element::class, $el);
$this->assertSame('span', $el->tag);
@@ -535,7 +540,7 @@ public function testSaleBadgeShownWhenOnSale(): void
}
```
-Views decompose the way any other code does: a class of small component methods, each testable in isolation, composed by a top-level method returning the final page.
+Views decompose the way any other code does: small component classes, each testable in isolation, composed by a top-level component returning the final page.
### Framework components - a Livewire-style clock
@@ -590,6 +595,7 @@ Some natural extensions are deliberately left out of this RFC to keep its scope
* **Context-aware escaping** (URL/JS/CSS contexts), as in Go's `html/template` or Latte's escaping, - so that e.g. a `javascript:` URL in `href` is caught. This RFC escapes HTML context only; the value-tree model is designed so this can be layered on later without a syntax change. Some examples include:
* A JavaScript escaping context inside `';
+echo (new Element('span', [], [$evil]))->__toString(), "\n";
+
+// --- attribute value coercion (RFC §5) ---
+
+// null and false omit the attribute; true is a bare boolean attribute.
+echo (new Element('input', [
+ 'type' => 'checkbox',
+ 'checked' => true,
+ 'disabled' => false,
+ 'value' => null,
+ 'tabindex' => 3,
+ 'data-ratio' => 1.5,
+]))->__toString(), "\n";
+
+// Stringable attribute value.
+$s = new class implements Stringable {
+ public function __toString(): string { return 'x&y'; }
+};
+echo (new Element('a', ['data-x' => $s]))->__toString(), "\n";
+
+// Arrays are reserved -> TypeError.
+try {
+ (new Element('div', ['class' => ['a', 'b']]))->__toString();
+} catch (\Throwable $e) {
+ echo get_class($e), ': ', $e->getMessage(), "\n";
+}
+
+// --- Htmlable attribute values (no double-escaping) ---
+
+// Html\raw(): trusted content, emitted verbatim (the entity survives).
+echo x, "\n";
+
+// Html\escape(): escaped exactly once, not re-escaped at render time.
+echo x, "\n";
+
+// Plain strings still escape by default.
+echo x, "\n";
+
+// A generic Stringable (not Htmlable) still escapes.
+class S { public function __toString(): string { return 'x & y'; } }
+echo x, "\n";
+
+// --- Fragment, void elements, raw()/escape() helpers ---
+
+// Fragment: no wrapper element, children concatenated.
+$frag = new Fragment([
+ new Element('h1', [], ['Title']),
+ new Element('p', [], ['Body']),
+]);
+echo $frag->__toString(), "\n";
+
+// Void elements emit clean HTML5 (no slash, no closing tag).
+echo (new Element('br'))->__toString(), "\n";
+echo (new Element('img', ['src' => '/a.png', 'alt' => 'A & B']))->__toString(), "\n";
+
+// raw(): trusted passthrough, not escaped.
+echo raw('x')->__toString(), "\n";
+
+// escape(): escaped once, then safe to embed without double-escaping.
+$safe = escape('x');
+echo $safe->__toString(), "\n";
+echo (new Element('p', [], [$safe]))->__toString(), "\n"; // not escaped again
+
+var_dump(raw('y') instanceof Html\Htmlable);
+
+// --- tag/attribute-name validation (security) ---
+
+function err(callable $fn): string {
+ try { return $fn()->__toString(); }
+ catch (\Throwable $e) { return get_class($e) . ': ' . $e->getMessage(); }
+}
+
+// Attribute name from an attacker-controlled spread key - must be rejected.
+echo err(fn() => new E('a', ['x" onmouseover="alert(1)' => 'y'], ['hi'])), "\n";
+
+// Tag name from a dynamic source - must be rejected.
+echo err(fn() => new E('a>', [], [])), "\n";
+
+// Empty names rejected.
+echo err(fn() => new E('', [], [])), "\n";
+echo err(fn() => new E('div', ['' => 'x'], [])), "\n";
+
+// Legitimate dynamic names still work (custom elements, data-/aria-, namespaces).
+echo (new E('my-widget', ['data-x' => '1', 'aria-label' => 'ok', 'xml:lang' => 'en'], ['x']))->__toString(), "\n";
+echo ( 5]}>ok), "\n";
+?>
+--EXPECTF--
+Click me
+Click me
+bool(true)
+bool(true)
+string(1) "a"
+array(2) {
+ ["href"]=>
+ string(15) "https://php.net"
+ ["class"]=>
+ string(3) "btn"
+}
+array(1) {
+ [0]=>
+ string(8) "Click me"
+}
+
Tom & Jerry <3 "quotes" 'apostrophe'
+
+<script>alert(1)</script>
+
+
+TypeError: Attribute "class" value must be of type string|int|float|bool|null|Stringable, array given
+x
+x
+x
+x
+
Title
Body
+
+
+x
+<b>x</b>
+
<b>x</b>
+bool(true)
+ValueError: Invalid attribute name "x" onmouseover="alert(1)"
+ValueError: Invalid tag name "a>"
+ValueError: Invalid tag name ""
+ValueError: Invalid attribute name ""
+x
+ok
diff --git a/ext/html/tests/element_basic.phpt b/ext/html/tests/element_basic.phpt
deleted file mode 100644
index 1fbd875a814a..000000000000
--- a/ext/html/tests/element_basic.phpt
+++ /dev/null
@@ -1,34 +0,0 @@
---TEST--
-Html\Element renders a tag with attributes and children
---EXTENSIONS--
-html
---FILE--
- 'https://php.net', 'class' => 'btn'], ['Click me']);
-echo $el->__toString(), "\n";
-echo $el, "\n"; // echo casts via __toString
-
-var_dump($el instanceof Html\Htmlable);
-var_dump($el instanceof Stringable);
-var_dump($el->tag);
-var_dump($el->attributes);
-var_dump($el->children);
-?>
---EXPECT--
-Click me
-Click me
-bool(true)
-bool(true)
-string(1) "a"
-array(2) {
- ["href"]=>
- string(15) "https://php.net"
- ["class"]=>
- string(3) "btn"
-}
-array(1) {
- [0]=>
- string(8) "Click me"
-}
diff --git a/ext/html/tests/escaping.phpt b/ext/html/tests/escaping.phpt
deleted file mode 100644
index 71c76c415159..000000000000
--- a/ext/html/tests/escaping.phpt
+++ /dev/null
@@ -1,22 +0,0 @@
---TEST--
-Html\Element escapes string children and attribute values by default
---EXTENSIONS--
-html
---FILE--
-__toString(), "\n";
-
-// Attribute values are escaped.
-echo (new Element('div', ['title' => 'a "b" & '], []))->__toString(), "\n";
-
-// A classic XSS attempt is neutralised.
-$evil = '';
-echo (new Element('span', [], [$evil]))->__toString(), "\n";
-?>
---EXPECT--
-
Tom & Jerry <3 "quotes" 'apostrophe'
-
-<script>alert(1)</script>
diff --git a/ext/html/tests/fragment_void_raw.phpt b/ext/html/tests/fragment_void_raw.phpt
deleted file mode 100644
index 0da6b5d359f3..000000000000
--- a/ext/html/tests/fragment_void_raw.phpt
+++ /dev/null
@@ -1,40 +0,0 @@
---TEST--
-Html\Fragment, void-element serialization, and raw()/escape() helpers
---EXTENSIONS--
-html
---FILE--
-__toString(), "\n";
-
-// Void elements emit clean HTML5 (no slash, no closing tag).
-echo (new Element('br'))->__toString(), "\n";
-echo (new Element('img', ['src' => '/a.png', 'alt' => 'A & B']))->__toString(), "\n";
-
-// raw(): trusted passthrough, not escaped.
-echo raw('x')->__toString(), "\n";
-
-// escape(): escaped once, then safe to embed without double-escaping.
-$safe = escape('x');
-echo $safe->__toString(), "\n";
-echo (new Element('p', [], [$safe]))->__toString(), "\n"; // not escaped again
-
-var_dump(raw('y') instanceof Html\Htmlable);
-?>
---EXPECT--
-
Title
Body
-
-
-x
-<b>x</b>
-
<b>x</b>
-bool(true)
diff --git a/ext/html/tests/htmlable.phpt b/ext/html/tests/htmlable.phpt
new file mode 100644
index 000000000000..89a55404989d
--- /dev/null
+++ b/ext/html/tests/htmlable.phpt
@@ -0,0 +1,171 @@
+--TEST--
+Html\Htmlable: toHtml() contract and recursive resolution, injected default __toString vs declared __toString, cycles and throwing implementations
+--EXTENSIONS--
+html
+--FILE--
+{$this->title}
;
+ }
+}
+
+$card = new Card('Hi & bye');
+var_dump($card->toHtml() instanceof Html\Element);
+var_dump($card->toHtml()->tag);
+
+// The injected default __toString renders via toHtml(), so echo, (string),
+// and interpolation all work without the class declaring __toString.
+echo $card, "\n";
+echo (string) $card, "\n";
+echo "wrapped: {$card}\n";
+var_dump($card instanceof Stringable);
+
+// In markup child position the component renders through toHtml() natively.
+echo {$card}, "\n";
+
+// And as a component tag.
+echo , "\n";
+
+// toHtml() may return another Htmlable (e.g. delegate to another component);
+// resolution recurses until it reaches a node class.
+class Fancy implements Html\Htmlable {
+ public function toHtml(): Html\Htmlable {
+ return new Card('delegated');
+ }
+}
+echo new Fancy(), "\n";
+
+// The node classes are the base cases: their toHtml() returns themselves.
+$el = x;
+var_dump($el->toHtml() === $el);
+$frag = <>y>;
+var_dump($frag->toHtml() === $frag);
+$raw = Html\raw('z');
+var_dump($raw->toHtml() === $raw);
+
+// --- injected default __toString vs declared __toString ---
+
+// A __toString declared by the class wins for string casts; markup rendering
+// always goes through toHtml(), so the two can serve different audiences
+// (e.g. a log-friendly string form) without affecting markup output.
+class Custom implements Html\Htmlable {
+ public function toHtml(): Html\Htmlable { return markup; }
+ public function __toString(): string { return 'CUSTOM'; }
+}
+echo new Custom(), "\n";
+echo {new Custom()}, "\n";
+
+// A __toString flattened in from a trait counts as declared.
+trait Loud {
+ public function __toString(): string { return 'LOUD'; }
+}
+class UsesTrait implements Html\Htmlable {
+ use Loud;
+ public function toHtml(): Html\Htmlable { return Html\raw('quiet'); }
+}
+echo new UsesTrait(), " / ", {new UsesTrait()}, "\n";
+
+// A __toString inherited from a parent class also wins.
+class StringyBase {
+ public function __toString(): string { return 'BASE'; }
+}
+class ChildOfStringy extends StringyBase implements Html\Htmlable {
+ public function toHtml(): Html\Htmlable { return Html\raw('child'); }
+}
+echo new ChildOfStringy(), " / ", {new ChildOfStringy()}, "\n";
+
+// An abstract class implementing Htmlable: concrete children inherit the
+// injected default.
+abstract class Widget implements Html\Htmlable {}
+class Button extends Widget {
+ public function toHtml(): Html\Htmlable { return ; }
+}
+echo new Button(), "\n";
+
+// An interface extending Htmlable stays abstract; implementers get the default.
+interface Panel extends Html\Htmlable {}
+class SidePanel implements Panel {
+ public function toHtml(): Html\Htmlable { return ; }
+}
+echo new SidePanel(), "\n";
+
+// The injected method is a real internal method: visible to reflection, with
+// the declared string return type, owned by the implementing class.
+$rm = new ReflectionMethod(SidePanel::class, '__toString');
+var_dump($rm->isInternal(), (string) $rm->getReturnType(), $rm->getDeclaringClass()->getName());
+var_dump(method_exists(new Button(), '__toString'));
+var_dump(is_callable([new Button(), '__toString']));
+echo (new Button())->__toString(), "\n";
+
+// --- resolution cycles and throwing implementations ---
+
+// toHtml() returning $this can never produce a node class: bounded, then Error.
+class Selfish implements Html\Htmlable {
+ public function toHtml(): Html\Htmlable { return $this; }
+}
+try { echo new Selfish(); }
+catch (Error $e) { echo get_class($e), ": ", $e->getMessage(), "\n"; }
+try { echo
{new Selfish()}
; }
+catch (Error $e) { echo get_class($e), ": ", $e->getMessage(), "\n"; }
+
+// A two-class cycle is caught by the same bound.
+class Ping implements Html\Htmlable {
+ public function toHtml(): Html\Htmlable { return new Pong(); }
+}
+class Pong implements Html\Htmlable {
+ public function toHtml(): Html\Htmlable { return new Ping(); }
+}
+try { echo new Ping(); }
+catch (Error $e) { echo get_class($e), ": ", $e->getMessage(), "\n"; }
+
+// An exception thrown inside toHtml() propagates cleanly through the injected
+// __toString (both directly and from child position).
+class Boom implements Html\Htmlable {
+ public function toHtml(): Html\Htmlable { throw new RuntimeException('boom'); }
+}
+try { echo new Boom(); }
+catch (RuntimeException $e) { echo get_class($e), ": ", $e->getMessage(), "\n"; }
+try { echo
+bool(true)
+bool(true)
+bool(true)
+CUSTOM
+markup
+LOUD / quiet
+BASE / child
+
+
+bool(true)
+string(6) "string"
+string(9) "SidePanel"
+bool(true)
+bool(true)
+
+Error: Maximum toHtml() resolution depth of 64 exceeded (Selfish::toHtml() never produced an Html\Element, Html\Fragment, or Html\Raw)
+Error: Maximum toHtml() resolution depth of 64 exceeded (Selfish::toHtml() never produced an Html\Element, Html\Fragment, or Html\Raw)
+Error: Maximum toHtml() resolution depth of 64 exceeded (Ping::toHtml() never produced an Html\Element, Html\Fragment, or Html\Raw)
+RuntimeException: boom
+RuntimeException: boom
+clean
diff --git a/ext/html/tests/htmlable_default_tostring.phpt b/ext/html/tests/htmlable_default_tostring.phpt
deleted file mode 100644
index f9963cc6c4d0..000000000000
--- a/ext/html/tests/htmlable_default_tostring.phpt
+++ /dev/null
@@ -1,71 +0,0 @@
---TEST--
-Html\Htmlable: the default __toString() is injected at class-link time and yields to a declared one
---EXTENSIONS--
-html
---FILE--
-markup; }
- public function __toString(): string { return 'CUSTOM'; }
-}
-echo new Custom(), "\n";
-echo {new Custom()}, "\n";
-
-// A __toString flattened in from a trait counts as declared.
-trait Loud {
- public function __toString(): string { return 'LOUD'; }
-}
-class UsesTrait implements Html\Htmlable {
- use Loud;
- public function toHtml(): Html\Htmlable { return Html\raw('quiet'); }
-}
-echo new UsesTrait(), " / ", {new UsesTrait()}, "\n";
-
-// A __toString inherited from a parent class also wins.
-class StringyBase {
- public function __toString(): string { return 'BASE'; }
-}
-class ChildOfStringy extends StringyBase implements Html\Htmlable {
- public function toHtml(): Html\Htmlable { return Html\raw('child'); }
-}
-echo new ChildOfStringy(), " / ", {new ChildOfStringy()}, "\n";
-
-// An abstract class implementing Htmlable: concrete children inherit the
-// injected default.
-abstract class Widget implements Html\Htmlable {}
-class Button extends Widget {
- public function toHtml(): Html\Htmlable { return ; }
-}
-echo new Button(), "\n";
-
-// An interface extending Htmlable stays abstract; implementers get the default.
-interface Panel extends Html\Htmlable {}
-class SidePanel implements Panel {
- public function toHtml(): Html\Htmlable { return ; }
-}
-echo new SidePanel(), "\n";
-
-// The injected method is a real internal method: visible to reflection, with
-// the declared string return type, owned by the implementing class.
-$rm = new ReflectionMethod(SidePanel::class, '__toString');
-var_dump($rm->isInternal(), (string) $rm->getReturnType(), $rm->getDeclaringClass()->getName());
-var_dump(method_exists(new Button(), '__toString'));
-var_dump(is_callable([new Button(), '__toString']));
-echo (new Button())->__toString(), "\n";
-?>
---EXPECT--
-CUSTOM
-markup
-LOUD / quiet
-BASE / child
-
-
-bool(true)
-string(6) "string"
-string(9) "SidePanel"
-bool(true)
-bool(true)
-
diff --git a/ext/html/tests/htmlable_tohtml.phpt b/ext/html/tests/htmlable_tohtml.phpt
deleted file mode 100644
index 8f045a8417b9..000000000000
--- a/ext/html/tests/htmlable_tohtml.phpt
+++ /dev/null
@@ -1,64 +0,0 @@
---TEST--
-Html\Htmlable::toHtml() is the render contract; __toString comes for free (RFC §3)
---EXTENSIONS--
-html
---FILE--
-{$this->title}
;
- }
-}
-
-$card = new Card('Hi & bye');
-var_dump($card->toHtml() instanceof Html\Element);
-var_dump($card->toHtml()->tag);
-
-// The injected default __toString renders via toHtml(), so echo, (string),
-// and interpolation all work without the class declaring __toString.
-echo $card, "\n";
-echo (string) $card, "\n";
-echo "wrapped: {$card}\n";
-var_dump($card instanceof Stringable);
-
-// In markup child position the component renders through toHtml() natively.
-echo {$card}, "\n";
-
-// And as a component tag.
-echo , "\n";
-
-// toHtml() may return another Htmlable (e.g. delegate to another component);
-// resolution recurses until it reaches a node class.
-class Fancy implements Html\Htmlable {
- public function toHtml(): Html\Htmlable {
- return new Card('delegated');
- }
-}
-echo new Fancy(), "\n";
-
-// The node classes are the base cases: their toHtml() returns themselves.
-$el = x;
-var_dump($el->toHtml() === $el);
-$frag = <>y>;
-var_dump($frag->toHtml() === $frag);
-$raw = Html\raw('z');
-var_dump($raw->toHtml() === $raw);
-?>
---EXPECT--
-bool(true)
-string(3) "div"
-
; }
-catch (Error $e) { echo get_class($e), ": ", $e->getMessage(), "\n"; }
-
-// A two-class cycle is caught by the same bound.
-class Ping implements Html\Htmlable {
- public function toHtml(): Html\Htmlable { return new Pong(); }
-}
-class Pong implements Html\Htmlable {
- public function toHtml(): Html\Htmlable { return new Ping(); }
-}
-try { echo new Ping(); }
-catch (Error $e) { echo get_class($e), ": ", $e->getMessage(), "\n"; }
-
-// An exception thrown inside toHtml() propagates cleanly through the injected
-// __toString (both directly and from child position).
-class Boom implements Html\Htmlable {
- public function toHtml(): Html\Htmlable { throw new RuntimeException('boom'); }
-}
-try { echo new Boom(); }
-catch (RuntimeException $e) { echo get_class($e), ": ", $e->getMessage(), "\n"; }
-try { echo
{new Boom()}
; }
-catch (RuntimeException $e) { echo get_class($e), ": ", $e->getMessage(), "\n"; }
-
-echo "clean\n";
-?>
---EXPECT--
-Error: Maximum toHtml() resolution depth of 64 exceeded (Selfish::toHtml() never produced an Html\Element, Html\Fragment, or Html\Raw)
-Error: Maximum toHtml() resolution depth of 64 exceeded (Selfish::toHtml() never produced an Html\Element, Html\Fragment, or Html\Raw)
-Error: Maximum toHtml() resolution depth of 64 exceeded (Ping::toHtml() never produced an Html\Element, Html\Fragment, or Html\Raw)
-RuntimeException: boom
-RuntimeException: boom
-clean
diff --git a/ext/html/tests/security_names.phpt b/ext/html/tests/security_names.phpt
deleted file mode 100644
index 8af0c52f2e86..000000000000
--- a/ext/html/tests/security_names.phpt
+++ /dev/null
@@ -1,34 +0,0 @@
---TEST--
-Html: dynamic tag/attribute names are validated so they can't break out (security)
---EXTENSIONS--
-html
---FILE--
-__toString(); }
- catch (\Throwable $e) { return get_class($e) . ': ' . $e->getMessage(); }
-}
-
-// Attribute name from an attacker-controlled spread key - must be rejected.
-echo err(fn() => new E('a', ['x" onmouseover="alert(1)' => 'y'], ['hi'])), "\n";
-
-// Tag name from a dynamic source - must be rejected.
-echo err(fn() => new E('a>', [], [])), "\n";
-
-// Empty names rejected.
-echo err(fn() => new E('', [], [])), "\n";
-echo err(fn() => new E('div', ['' => 'x'], [])), "\n";
-
-// Legitimate dynamic names still work (custom elements, data-/aria-, namespaces).
-echo (new E('my-widget', ['data-x' => '1', 'aria-label' => 'ok', 'xml:lang' => 'en'], ['x']))->__toString(), "\n";
-echo ( 5]}>ok), "\n";
-?>
---EXPECTF--
-ValueError: Invalid attribute name "x" onmouseover="alert(1)"
-ValueError: Invalid tag name "a>"
-ValueError: Invalid tag name ""
-ValueError: Invalid attribute name ""
-x
-ok
diff --git a/ext/html/tests/slot_attribute.phpt b/ext/html/tests/slot_attribute.phpt
deleted file mode 100644
index 8e91dbf46ef8..000000000000
--- a/ext/html/tests/slot_attribute.phpt
+++ /dev/null
@@ -1,30 +0,0 @@
---TEST--
-Html\Slot is a bare parameter-target marker attribute (RFC §5)
---EXTENSIONS--
-html
---FILE--
-getParameters() as $param) {
- foreach ($param->getAttributes(Slot::class) as $attr) {
- $slot = $attr->newInstance();
- printf("%s -> %s\n", $param->getName(), get_class($slot));
- }
-}
-
-// Slot takes no arguments and only targets parameters.
-$attr = (new ReflectionClass(Slot::class))->getAttributes(Attribute::class)[0]->newInstance();
-var_dump($attr->flags === Attribute::TARGET_PARAMETER);
-?>
---EXPECT--
-body -> Html\Slot
-bool(true)
diff --git a/ext/html/tests/syntax_attr_prefix.phpt b/ext/html/tests/syntax_attr_prefix.phpt
deleted file mode 100644
index f168070dd6c8..000000000000
--- a/ext/html/tests/syntax_attr_prefix.phpt
+++ /dev/null
@@ -1,72 +0,0 @@
---TEST--
-Markup syntax: ":" / "@" as an optional leading character of attribute names (RFC §4)
---EXTENSIONS--
-html
---FILE--
-t
, "\n";
-echo , "\n";
-
-// The prefix composes with the rest of the name charset (Alpine long forms,
-// modifiers) - only the *leading* character is new.
-echo , "\n";
-
-// Prefixed keys also pass the runtime name validation: spread and new Element.
-$attrs = ['@click' => 'save()', ':class' => 'cls'];
-echo s, "\n";
-echo new Html\Element('i', ['@x' => '1'], []), "\n";
-
-// On a component the names were never valid named-argument labels, so - like
-// data-* - they are collected by a variadic.
-class Btn implements Html\Htmlable {
- private array $attrs;
- public function __construct(public string $label, ...$attrs) { $this->attrs = $attrs; }
- public function toHtml(): Html\Htmlable {
- return ;
- }
-}
-echo , "\n";
-
-// Attribute values are still escaped like any other.
-echo
x
, "\n";
-
-// Attribute position only: a prefixed name can never begin a tag. At top
-// level "<:" / "<@" do not start markup at all; nested in markup content a
-// stray "<" gives the dedicated error.
-try {
- eval('$x =
;');
-} catch (ParseError $e) {
- echo $e->getMessage(), "\n";
-}
-
-// The prefix must be followed by a letter, and closing tags take no attributes.
-try {
- eval('$x =
-Unescaped "<" in markup text; write {'<'} for a literal less-than sign
-Unescaped "<" in markup text; write {'<'} for a literal less-than sign
-syntax error, unexpected T_ERROR "@", expecting markup tag name or markup tag end or markup self-close or markup interpolation start
-syntax error, unexpected T_ERROR "@", expecting markup tag end
diff --git a/ext/html/tests/syntax_attributes.phpt b/ext/html/tests/syntax_attributes.phpt
index f1d6ad4714ac..2d825a3fcea1 100644
--- a/ext/html/tests/syntax_attributes.phpt
+++ b/ext/html/tests/syntax_attributes.phpt
@@ -1,9 +1,12 @@
--TEST--
-Markup syntax: attributes (literal, {expr}, bare boolean) with coercion (RFC §5)
+Markup syntax: attributes — literal/{expr}/bare-boolean with coercion, {...spread}, ":"/"@" prefixed names (RFC §4, §5)
--EXTENSIONS--
html
--FILE--
x
)->__toString(), "\n";
// self-closing element with attributes
echo ()->__toString(), "\n";
+
+// --- {...$attrs} spread: elements (array merge) ---
+// Element: spread merges into the attribute map; values are still escaped.
+$extra = ['data-id' => 7, 'class' => 'from-spread', 'title' => 'T & T'];
+echo (link)->__toString(), "\n";
+
+// Conflict resolution follows array-merge order (last wins).
+echo ( 'override']}>x)->__toString(), "\n";
+echo ( 'base']} class="explicit">x)->__toString(), "\n";
+
+// --- {...$attrs} spread: components (named args) ---
+// Component: spread becomes named arguments (props).
+class Card implements Html\Htmlable {
+ public function __construct(public string $title, public string $tone = 'plain') {}
+ public function toHtml(): Html\Htmlable {
+ return new E('div', ['class' => 'card-' . $this->tone], [$this->title]);
+ }
+}
+$props = ['title' => 'Hi & bye', 'tone' => 'loud'];
+echo ()->__toString(), "\n";
+echo ( 'soft']}/>)->__toString(), "\n";
+
+// A component variadic collects unknown (e.g. hyphenated) spread keys.
+class Box implements Html\Htmlable {
+ private array $attrs;
+ public function __construct(public string $kind, ...$attrs) { $this->attrs = $attrs; }
+ public function toHtml(): Html\Htmlable {
+ return new E('div', ['class' => $this->kind, ...$this->attrs], ['box']);
+ }
+}
+echo ( '1', 'role' => 'note']}/>)->__toString(), "\n";
+
+// --- ":" / "@" as an optional leading character of attribute names ---
+// Vue/Alpine shorthand attribute names: literal value, {expr} value, bare boolean.
+$open = 'open';
+echo
t
, "\n";
+echo , "\n";
+
+// The prefix composes with the rest of the name charset (Alpine long forms,
+// modifiers) - only the *leading* character is new.
+echo , "\n";
+
+// Prefixed keys also pass the runtime name validation: spread and new Element.
+$attrs = ['@click' => 'save()', ':class' => 'cls'];
+echo s, "\n";
+echo new Html\Element('i', ['@x' => '1'], []), "\n";
+
+// On a component the names were never valid named-argument labels, so - like
+// data-* - they are collected by a variadic.
+class Btn implements Html\Htmlable {
+ private array $attrs;
+ public function __construct(public string $label, ...$attrs) { $this->attrs = $attrs; }
+ public function toHtml(): Html\Htmlable {
+ return ;
+ }
+}
+echo , "\n";
+
+// Attribute values are still escaped like any other.
+echo
x
, "\n";
+
+// Attribute position only: a prefixed name can never begin a tag. At top
+// level "<:" / "<@" do not start markup at all; nested in markup content a
+// stray "<" gives the dedicated error.
+try {
+ eval('$x =
;');
+} catch (ParseError $e) {
+ echo $e->getMessage(), "\n";
+}
+
+// The prefix must be followed by a letter, and closing tags take no attributes.
+try {
+ eval('$x =
)->__toString(), "\n";
+
+// --- JSX-style whitespace normalization ---
+// Newlines + indentation between block elements are dropped.
+echo (
+
one
+
two
+
)->__toString(), "\n";
+
+// A meaningful single space between inline content is preserved.
+$who = 'Ada';
+echo (
Hello {$who}, welcome back
)->__toString(), "\n";
+
+// Leading/trailing blank lines and indentation around text collapse away.
+echo (
+ Just some text.
+
)->__toString(), "\n";
+
+// Words on separate lines join with a single space.
+echo (
+ one
+ two
+ three
+
)->__toString(), "\n";
+
+// Inline trailing space with no newline is kept (so adjacent elements don't fuse).
+echo (
a b c
)->__toString(), "\n";
+
+// --- childless elements: empty or self-closed are equivalent ---
+// and are equivalent - both allowed, so real HTML can be
+// pasted into markup unchanged.
+var_dump((string) === (string) );
+echo , "\n";
+echo , "\n";
+
+// A whitespace-only multi-line body normalizes away to the same empty element.
+echo
+
, "\n";
+
+// Void elements still serialize with no closing tag, from either source form.
+echo , "\n";
+echo , "\n";
+
+// --- self-closing source; serializer expands non-void elements ---
+// Void elements serialize without a closing tag...
+echo ()->__toString(), "\n";
+echo ()->__toString(), "\n";
+// ...non-void self-closed source expands to open+close output.
+echo ()->__toString(), "\n";
+echo ()->__toString(), "\n";
+
+// Empty components and fragments are allowed (only HTML elements must self-close).
+class C implements Html\Htmlable {
+ public function toHtml(): Html\Htmlable { return Html\raw('C'); }
+}
+echo ()->__toString(), "\n";
+var_dump((<>>)->__toString());
+
+// --- literal braces via character references or {'{'} ---
+// { begins interpolation; a literal { is written as in HTML or JSX.
+
+// Character references decode to literal braces at compile time (no interpolation).
+echo (
{x}
)->__toString(), "\n";
+echo (
{x}
)->__toString(), "\n";
+
+// The JSX form: an interpolated string literal.
+echo (
{'{'}x{'}'}
)->__toString(), "\n";
+
+// A lone } in text is already literal.
+echo (
a } b
)->__toString(), "\n";
+
+// Literal JSON in a )->__toString(), "\n";
+
+// --- comments: is literal output; {/* */} renders nothing ---
+$x = 'ignored';
+
+// is emitted verbatim; its content is literal (no interpolation).
+echo (
body
)->__toString(), "\n";
+
+// {/* ... */} is a source-only comment and renders nothing.
+echo (
a{/* dropped */}b
)->__toString(), "\n";
+
+// an empty interpolation renders nothing too
+echo (
x{}y
)->__toString(), "\n";
+
+// comment between block elements survives (it is real output, not whitespace)
+echo (
+
+
one
+
)->__toString(), "\n";
?>
--EXPECT--
bool(true)
Hello Ada & <friends> world
-
-
bool(true)
Title
Body Ada & <friends>
x
y
yes
+
one
two
+
Hello Ada, welcome back
+
Just some text.
+
one two three
+
a b c
+bool(true)
+
+
+
+
+
+
+
+
+
+C
+string(0) ""
+
{x}
+
{x}
+
{x}
+
a } b
+
+
body
+
ab
+
xy
+
one
diff --git a/ext/html/tests/syntax_bc.phpt b/ext/html/tests/syntax_bc.phpt
deleted file mode 100644
index 82aafd3e1d55..000000000000
--- a/ext/html/tests/syntax_bc.phpt
+++ /dev/null
@@ -1,46 +0,0 @@
---TEST--
-Markup syntax: bare "<" in operator position keeps comparison/shift/not-equal meaning
---EXTENSIONS--
-html
---FILE--
- $b); // legacy not-equal
-var_dump($a <=> $b); // spaceship
-var_dump($a <= $b); // less-or-equal
-var_dump(1 << 4); // shift left
-var_dump(64 >> 2); // shift right
-
-// Heredoc (which also starts with "<") is unaffected.
-echo << text
-EOT, "\n";
-
-// Error suppression still works.
-$x = []; var_dump(@$x['missing']);
-?>
---EXPECT--
-bool(true)
-bool(true)
-bool(true)
-bool(true)
-bool(true)
-bool(true)
-bool(true)
-int(-1)
-bool(true)
-int(16)
-int(16)
-literal text
-NULL
diff --git a/ext/html/tests/syntax_braces.phpt b/ext/html/tests/syntax_braces.phpt
deleted file mode 100644
index 8babd1bfc2f9..000000000000
--- a/ext/html/tests/syntax_braces.phpt
+++ /dev/null
@@ -1,30 +0,0 @@
---TEST--
-Markup syntax: literal braces via character references or {'{'} (RFC §6)
---EXTENSIONS--
-html
---FILE--
-{x})->__toString(), "\n";
-echo (
{x}
)->__toString(), "\n";
-
-// The JSX form: an interpolated string literal.
-echo (
{'{'}x{'}'}
)->__toString(), "\n";
-
-// A lone } in text is already literal.
-echo (
a } b
)->__toString(), "\n";
-
-// Literal JSON in a )->__toString(), "\n";
-?>
---EXPECT--
-
{x}
-
{x}
-
{x}
-
a } b
-
diff --git a/ext/html/tests/syntax_childless.phpt b/ext/html/tests/syntax_childless.phpt
deleted file mode 100644
index 1c6d0ddedd92..000000000000
--- a/ext/html/tests/syntax_childless.phpt
+++ /dev/null
@@ -1,27 +0,0 @@
---TEST--
-Markup syntax: childless elements may be written empty or self-closed (RFC §6)
---EXTENSIONS--
-html
---FILE--
-
and are equivalent - both allowed, so real HTML can be
-// pasted into markup unchanged.
-var_dump((string) === (string) );
-echo , "\n";
-echo , "\n";
-
-// A whitespace-only multi-line body normalizes away to the same empty element.
-echo
-
, "\n";
-
-// Void elements still serialize with no closing tag, from either source form.
-echo , "\n";
-echo , "\n";
-?>
---EXPECT--
-bool(true)
-
-
-
-
-
diff --git a/ext/html/tests/syntax_comments.phpt b/ext/html/tests/syntax_comments.phpt
deleted file mode 100644
index a0f3fa6cf0e7..000000000000
--- a/ext/html/tests/syntax_comments.phpt
+++ /dev/null
@@ -1,28 +0,0 @@
---TEST--
-Markup syntax: is a literal comment; {/* */} renders nothing (RFC §7)
---EXTENSIONS--
-html
---FILE--
- is emitted verbatim; its content is literal (no interpolation).
-echo (
body
)->__toString(), "\n";
-
-// {/* ... */} is a source-only comment and renders nothing.
-echo (
a{/* dropped */}b
)->__toString(), "\n";
-
-// an empty interpolation renders nothing too
-echo (
x{}y
)->__toString(), "\n";
-
-// comment between block elements survives (it is real output, not whitespace)
-echo (
-
-
one
-
)->__toString(), "\n";
-?>
---EXPECT--
-
body
-
ab
-
xy
-
one
diff --git a/ext/html/tests/syntax_component_hyphenated.phpt b/ext/html/tests/syntax_component_hyphenated.phpt
deleted file mode 100644
index acde45c995fe..000000000000
--- a/ext/html/tests/syntax_component_hyphenated.phpt
+++ /dev/null
@@ -1,35 +0,0 @@
---TEST--
-Markup syntax: hyphenated attributes on components route through named args (RFC §5)
---EXTENSIONS--
-html
---FILE--
-attrs = $attrs; }
- public function toHtml(): Html\Htmlable {
- return new E('div', ['class' => $this->kind, ...$this->attrs], ['box']);
- }
-}
-
-// Hyphenated attributes work directly on a component (no spread needed) - they
-// become named arguments and the variadic collects them.
-echo (), "\n";
-
-// A component without a variadic rejects an unknown (hyphenated) attribute.
-class Tight implements Html\Htmlable {
- public function __construct(public string $kind) {}
- public function toHtml(): Html\Htmlable { return new E('div', ['class' => $this->kind], ['y']); }
-}
-try {
- echo (), "\n";
-} catch (\Throwable $e) {
- echo get_class($e), ': ', $e->getMessage(), "\n";
-}
-?>
---EXPECT--
-
box
-Error: Unknown named parameter $data-x
diff --git a/ext/html/tests/syntax_component_namespace.phpt b/ext/html/tests/syntax_component_namespace.phpt
deleted file mode 100644
index 7ee22068f0cc..000000000000
--- a/ext/html/tests/syntax_component_namespace.phpt
+++ /dev/null
@@ -1,37 +0,0 @@
---TEST--
-Markup syntax: component names are resolved at compile time honoring use/namespace (RFC §4)
---EXTENSIONS--
-html
---FILE--
- 'card'], [$this->title]);
- }
- }
-}
-
-namespace App\Views {
- use App\Components\Card;
- // resolves to App\Components\Card via the `use` alias, not a global Card.
- echo ()->__toString(), "\n";
- echo (
)->__toString(), "\n";
-}
-
-namespace Other {
- // A fully-qualified-feeling name via current namespace: define a local component.
- use Html\Element as E;
- class Box implements \Html\Htmlable {
- public function __construct(public string $label) {}
- public function toHtml(): \Html\Htmlable { return new E('b', [], [$this->label]); }
- }
- echo ()->__toString(), "\n";
-}
-?>
---EXPECT--
-
Hi & ok
-
Nested
-local
diff --git a/ext/html/tests/syntax_component_qualified.phpt b/ext/html/tests/syntax_component_resolution.phpt
similarity index 57%
rename from ext/html/tests/syntax_component_qualified.phpt
rename to ext/html/tests/syntax_component_resolution.phpt
index d5e317e1b165..3dd6aabc823b 100644
--- a/ext/html/tests/syntax_component_qualified.phpt
+++ b/ext/html/tests/syntax_component_resolution.phpt
@@ -1,9 +1,20 @@
--TEST--
-Markup syntax: qualified/fully-qualified component tags and use resolution (RFC §4)
+Markup syntax: compile-time component-name resolution (use aliases, qualified/fully-qualified tags, static methods) (RFC §4)
--EXTENSIONS--
html
--FILE--
'card'], [$this->title]);
+ }
+ }
+}
+
namespace App {
class Greeting implements \Html\Htmlable {
public function __construct(#[\Html\Slot] public ?\Html\Htmlable $slot = null) {}
@@ -24,12 +35,22 @@ namespace App {
}
}
+// --- use-alias resolution from another namespace ---
+namespace App\Views {
+ use App\Components\Card;
+ // resolves to App\Components\Card via the `use` alias, not a global Card.
+ echo ()->__toString(), "\n";
+ echo (
)->__toString(), "\n";
+}
+
+// --- qualified (namespace-relative) tags from global scope ---
namespace {
// A qualified name is namespace-relative; from global scope App\Greeting is App\Greeting.
echo (hi)->__toString(), "\n";
echo ()->__toString(), "\n";
}
+// --- fully-qualified tags, use imports, static-method class-part resolution ---
namespace Views {
// A fully-qualified <\...> tag ignores the current namespace.
echo (<\App\Greeting>fq\App\Greeting>)->__toString(), "\n";
@@ -45,6 +66,17 @@ namespace Views {
echo ()->__toString(), "\n";
}
+// --- component defined and used in the current namespace ---
+namespace Other {
+ use Html\Element as E;
+ class Box implements \Html\Htmlable {
+ public function __construct(public string $label) {}
+ public function toHtml(): \Html\Htmlable { return new E('b', [], [$this->label]); }
+ }
+ echo ()->__toString(), "\n";
+}
+
+// --- unimported bare tag fails: no global fallback ---
namespace Wrong {
// Without an import, a bare tag resolves in the current namespace only —
// exactly how PHP resolves an unqualified class name (no global fallback).
@@ -56,10 +88,13 @@ namespace Wrong {
}
?>
--EXPECT--
+
Hi & ok
+
Nested
hi
G
fq
CBy AdaBy Grace
+local
"Wrong\Card" is not a component: no such class implementing Html\Htmlable
diff --git a/ext/html/tests/syntax_components.phpt b/ext/html/tests/syntax_components.phpt
deleted file mode 100644
index 17cd232fe8ad..000000000000
--- a/ext/html/tests/syntax_components.phpt
+++ /dev/null
@@ -1,60 +0,0 @@
---TEST--
-Markup syntax: component tags lower to Html\render_component (RFC §4)
---EXTENSIONS--
-html
---FILE--
- 'card'], [new E('h2', [], [$this->title]), $this->body]);
- }
-}
-
-// A second, minimal component for nesting cases.
-class Badge implements Html\Htmlable {
- public function __construct(public string $label) {}
- public function toHtml(): Html\Htmlable {
- return new E('span', ['class' => 'badge'], [$this->label]);
- }
-}
-
-// Static-method component - multiple components can live together on one class.
-class Author {
- public static function byline(string $name): Html\Htmlable {
- return new E('p', ['class' => 'by'], ['By ', $name]);
- }
-}
-
-$who = 'Ada & co';
-
-// class component: attribute prop + body routed to the anonymous slot
-echo (
Hi {$who}
)->__toString(), "\n";
-
-// self-closing class component (no body -> slot is null)
-echo ()->__toString(), "\n";
-
-// static-method component
-echo ()->__toString(), "\n";
-
-// the same dispatch, called directly by name
-echo Html\render_component(Badge::class, ['label' => 'New &'])->__toString(), "\n";
-
-// a component used as a child of an HTML element, and inside interpolation
-echo (
)->__toString(), "\n";
-echo (
{array_map(fn($t) => , ['a', 'b'])}
)->__toString(), "\n";
-?>
---EXPECT--
-
Ada & co
Hi Ada & co
-
Solo
-
By Ada & co
-New &
-
x
-
ab
diff --git a/ext/html/tests/syntax_doctype.phpt b/ext/html/tests/syntax_doctype.phpt
index 523ed6c1a7ce..694cc85b50d8 100644
--- a/ext/html/tests/syntax_doctype.phpt
+++ b/ext/html/tests/syntax_doctype.phpt
@@ -1,9 +1,10 @@
--TEST--
-Markup syntax: is allowed in content position and emitted verbatim (RFC §6)
+Markup syntax: emission rules and non-doctype ;');
+} catch (ParseError $e) {
+ echo $e->getMessage(), "\n";
+}
+try {
+ eval('$v = ;');
+} catch (ParseError $e) {
+ echo $e->getMessage(), "\n";
+}
+// An unterminated doctype must not scan past the end of the file.
+try {
+ eval('$v =
+Unsupported markup declaration; only comments and are supported
+Unsupported markup declaration; only comments and are supported
+%s
diff --git a/ext/html/tests/syntax_doctype_errors.phpt b/ext/html/tests/syntax_doctype_errors.phpt
deleted file mode 100644
index 0024ab93db9a..000000000000
--- a/ext/html/tests/syntax_doctype_errors.phpt
+++ /dev/null
@@ -1,27 +0,0 @@
---TEST--
-Markup syntax: non-doctype ;');
-} catch (ParseError $e) {
- echo $e->getMessage(), "\n";
-}
-try {
- eval('$v = ;');
-} catch (ParseError $e) {
- echo $e->getMessage(), "\n";
-}
-// An unterminated doctype must not scan past the end of the file.
-try {
- eval('$v =
getMessage(), "\n";
-}
-?>
---EXPECTF--
-Unsupported markup declaration; only comments and are supported
-Unsupported markup declaration; only comments and are supported
-%s
diff --git a/ext/html/tests/syntax_dynamic.phpt b/ext/html/tests/syntax_dynamic.phpt
index e4b9276fc05c..5cb6f557ae0b 100644
--- a/ext/html/tests/syntax_dynamic.phpt
+++ b/ext/html/tests/syntax_dynamic.phpt
@@ -1,9 +1,10 @@
--TEST--
-Markup syntax: dynamic tags <$tag>…$tag> (RFC §4 variable elements/components)
+Markup syntax: dynamic tags <$tag>/<{expr}> — classification, escaping, single evaluation, BC, error cases (RFC §4)
--EXTENSIONS--
html
--FILE--
variable elements ---
// An element name: behaves exactly like the static tag.
$tag = 'div';
$el = <$tag class="box">Hello$tag>;
@@ -21,6 +22,7 @@ $tag = 'em';
$attrs = ['class' => 'x'];
echo
<$tag {...$attrs}>hi$tag>
, "\n";
+// --- runtime classification: components ---
// A class-component name (::class gives the FQCN): full component dispatch.
class Card implements Html\Htmlable {
public function __construct(
@@ -44,11 +46,13 @@ class Author {
$component = 'Author::byline';
echo <$component name="Ada"/>, "\n";
+// --- escaping ---
// Interpolated content is still escaped, whatever the tag value.
$tag = 'span';
$evil = '';
echo <$tag>{$evil}$tag>, "\n";
+// --- <{expr}> brace form: arbitrary expression, anonymous close ---
// The brace form takes an arbitrary expression, evaluated once, and closes
// anonymously with > (or self-closes).
class Registry {
@@ -68,14 +72,79 @@ var_dump($calls);
// Brace tags nest like any other element.
echo
<{'em'}>in here>
, "\n";
-// In operator position "<$" keeps its comparison meaning.
+// --- operator-position "<$" keeps its comparison meaning (BC) ---
$a = 5;
$b = 9;
var_dump($a <$b, $b <$a);
+// --- render_dynamic() direct calls ---
// The runtime target is a public function, same as render_component().
echo Html\render_dynamic('p', ['class' => 'y'], ['direct']), "\n";
echo Html\render_dynamic(Card::class, ['title' => 'Direct']), "\n";
+
+// --- error cases ---
+// Mismatched closing variable is a compile error, like
for .
+try {
+ eval('$x = <$a>hi$b>;');
+} catch (CompileError $e) {
+ echo $e->getMessage(), "\n";
+}
+
+// A brace tag closes anonymously; a named or variable close is a parse error
+// (the expression cannot be restated without re-evaluating it).
+foreach (['$x = <{"div"}>a
;', '$x = <{"div"}>a$t>;'] as $code) {
+ try {
+ eval($code);
+ } catch (ParseError $e) {
+ echo $e->getMessage(), "\n";
+ }
+}
+
+// An empty tag value is rejected up front.
+try {
+ Html\render_dynamic('');
+} catch (ValueError $e) {
+ echo $e->getMessage(), "\n";
+}
+
+// A value that is no valid tag name fails at serialization (the existing
+// Element guard) - a dynamic name can never break out of the markup.
+$tag = 'di v';
+try {
+ echo (string) <$tag/>;
+} catch (Error $e) {
+ echo $e->getMessage(), "\n";
+}
+
+// A lowercase value is always an element, even if a function of that name
+// exists (the same rule that makes static an element).
+$tag = 'date';
+echo <$tag/>, "\n";
+
+// A capitalized value can never reach an internal function like Date().
+$tag = 'Date';
+try {
+ echo <$tag/>;
+} catch (Error $e) {
+ echo $e->getMessage(), "\n";
+}
+
+// A capitalized value with no symbol behind it.
+$tag = 'NoSuchComponent';
+try {
+ echo <$tag/>;
+} catch (Error $e) {
+ echo $e->getMessage(), "\n";
+}
+
+// A "Class::method" value dispatches as a static-method component; a missing
+// class is the same hard error the static tag form gives.
+$tag = 'NoSuchRegistry::make';
+try {
+ echo <$tag/>;
+} catch (Error $e) {
+ echo $e->getMessage(), "\n";
+}
?>
--EXPECT--
bool(true)
@@ -97,3 +166,12 @@ bool(true)
bool(false)
direct
Direct
+Mismatched markup closing tag: expected $a>, found $b>
+syntax error, unexpected markup tag name "div", expecting markup tag end
+syntax error, unexpected variable "$t", expecting markup tag end
+Html\render_dynamic(): Argument #1 ($tag) cannot be empty
+Invalid tag name "di v"
+
+"Date" is not a component: no such class implementing Html\Htmlable
+"NoSuchComponent" is not a component: no such class implementing Html\Htmlable
+Component class "NoSuchRegistry" not found
diff --git a/ext/html/tests/syntax_dynamic_errors.phpt b/ext/html/tests/syntax_dynamic_errors.phpt
deleted file mode 100644
index 156571708eaf..000000000000
--- a/ext/html/tests/syntax_dynamic_errors.phpt
+++ /dev/null
@@ -1,79 +0,0 @@
---TEST--
-Markup syntax: dynamic tag error cases (mismatched close, invalid names)
---EXTENSIONS--
-html
---FILE--
- for .
-try {
- eval('$x = <$a>hi$b>;');
-} catch (CompileError $e) {
- echo $e->getMessage(), "\n";
-}
-
-// A brace tag closes anonymously; a named or variable close is a parse error
-// (the expression cannot be restated without re-evaluating it).
-foreach (['$x = <{"div"}>a;', '$x = <{"div"}>a$t>;'] as $code) {
- try {
- eval($code);
- } catch (ParseError $e) {
- echo $e->getMessage(), "\n";
- }
-}
-
-// An empty tag value is rejected up front.
-try {
- Html\render_dynamic('');
-} catch (ValueError $e) {
- echo $e->getMessage(), "\n";
-}
-
-// A value that is no valid tag name fails at serialization (the existing
-// Element guard) - a dynamic name can never break out of the markup.
-$tag = 'di v';
-try {
- echo (string) <$tag/>;
-} catch (Error $e) {
- echo $e->getMessage(), "\n";
-}
-
-// A lowercase value is always an element, even if a function of that name
-// exists (the same rule that makes static an element).
-$tag = 'date';
-echo <$tag/>, "\n";
-
-// A capitalized value can never reach an internal function like Date().
-$tag = 'Date';
-try {
- echo <$tag/>;
-} catch (Error $e) {
- echo $e->getMessage(), "\n";
-}
-
-// A capitalized value with no symbol behind it.
-$tag = 'NoSuchComponent';
-try {
- echo <$tag/>;
-} catch (Error $e) {
- echo $e->getMessage(), "\n";
-}
-
-// A "Class::method" value dispatches as a static-method component; a missing
-// class is the same hard error the static tag form gives.
-$tag = 'NoSuchRegistry::make';
-try {
- echo <$tag/>;
-} catch (Error $e) {
- echo $e->getMessage(), "\n";
-}
-?>
---EXPECT--
-Mismatched markup closing tag: expected $a>, found $b>
-syntax error, unexpected markup tag name "div", expecting markup tag end
-syntax error, unexpected variable "$t", expecting markup tag end
-Html\render_dynamic(): Argument #1 ($tag) cannot be empty
-Invalid tag name "di v"
-
-"Date" is not a component: no such class implementing Html\Htmlable
-"NoSuchComponent" is not a component: no such class implementing Html\Htmlable
-Component class "NoSuchRegistry" not found
diff --git a/ext/html/tests/syntax_entities.phpt b/ext/html/tests/syntax_entities.phpt
index bf241ad4263a..5b856549287b 100644
--- a/ext/html/tests/syntax_entities.phpt
+++ b/ext/html/tests/syntax_entities.phpt
@@ -1,9 +1,11 @@
--TEST--
-Markup syntax: HTML character references decode in text and attribute values (RFC §7)
+Markup syntax: HTML character references decode in text/attributes; tokenizer keeps them raw (RFC §7)
--EXTENSIONS--
html
+tokenizer
--FILE--
Fish & chips — £5, "\n";
@@ -17,19 +19,23 @@ echo
ABC 😀 ≫⃒
, "\n";
// is a real U+00A0, not the entity text (htmlspecialchars leaves it alone).
var_dump((string) === "\u{00A0}");
+// --- lenient handling of invalid references ---
// Lenient like HTML/JSX: unknown names, bare "&", missing semicolons,
// and invalid numerics (zero, surrogate, out of range, empty) stay literal
// and are escaped on output.
echo
a & b &nosuchentity; & c
, "\n";
echo
G;
, "\n";
+// --- attribute values ---
// Attribute values decode too; the "&" re-escapes when serialized.
echo x, "\n";
+// --- entities cannot smuggle markup structure ---
// lt/gt decode to real angle brackets in the value, escaped on output --
// so entities cannot smuggle markup structure.
echo
<script>alert(1)</script>
, "\n";
+// --- interaction with whitespace normalization, comments, interpolation ---
// Entities decode after whitespace normalization, so survives where a
// literal trailing space would be trimmed (the entity spelling of JSX {' '}).
echo
a
@@ -40,6 +46,17 @@ echo
, "\n";
// Interpolated strings are never entity-decoded; they are plain PHP values.
echo
{"&"}
, "\n";
+
+// --- tokenizer round-trip ---
+// Entity decoding happens only for the compiler; the tokenizer must see the
+// raw source text so token streams concatenate back to the original file.
+$code = 'Fish & chips ❤; ?>';
+
+$joined = '';
+foreach (token_get_all($code) as $token) {
+ $joined .= is_array($token) ? $token[1] : $token;
+}
+var_dump($joined === $code);
?>
--EXPECT--
-)->__toString(), "\n";
-
-// A meaningful single space between inline content is preserved.
-$name = 'Ada';
-echo (
Hello {$name}, welcome back
)->__toString(), "\n";
-
-// Leading/trailing blank lines and indentation around text collapse away.
-echo (
- Just some text.
-
)->__toString(), "\n";
-
-// Words on separate lines join with a single space.
-echo (
- one
- two
- three
-
)->__toString(), "\n";
-
-// Inline trailing space with no newline is kept (so adjacent elements don't fuse).
-echo (
a b c
)->__toString(), "\n";
-?>
---EXPECT--
-
one
two
-
Hello Ada, welcome back
-
Just some text.
-
one two three
-
a b c
From 1288f268528b88e23849927f1f68fc30a7000a2f Mon Sep 17 00:00:00 2001
From: Liam Hammett
Date: Thu, 9 Jul 2026 23:23:22 +0100
Subject: [PATCH 10/17] Refactor zend_markup_normalize_text to simplify line
handling and reduce memory usage
---
Zend/zend_markup.c | 109 ++++++++++++++-------------------------------
1 file changed, 34 insertions(+), 75 deletions(-)
diff --git a/Zend/zend_markup.c b/Zend/zend_markup.c
index e0eb142ae98c..9bbf40124c85 100644
--- a/Zend/zend_markup.c
+++ b/Zend/zend_markup.c
@@ -44,100 +44,59 @@
* - join the surviving lines with a single space.
* The net effect: indentation/newlines between block elements vanish, while a
* meaningful single space between inline content is preserved. Returns a new
- * zend_string (possibly empty — the caller drops empty text nodes). */
-typedef struct { const char *p; size_t n; } zend_markup_line;
-
-static zend_always_inline zend_markup_line *zend_markup_lines_grow(
- zend_markup_line *lines, zend_markup_line *stackbuf, uint32_t count, uint32_t *cap)
-{
- *cap *= 2;
- zend_markup_line *grown = emalloc(*cap * sizeof(zend_markup_line));
- memcpy(grown, lines, count * sizeof(zend_markup_line));
- if (lines != stackbuf) {
- efree(lines);
- }
- return grown;
-}
-
+ * zend_string (possibly empty — the caller drops empty text nodes).
+ *
+ * One streaming pass: because a non-first line is left-trimmed and a non-last
+ * line is right-trimmed, any line that survives contains a non-whitespace
+ * character, so "join surviving lines with a single space" is simply a space
+ * before every surviving line except the first — no line table or lookahead
+ * for the last non-blank line is needed. A single-line text is untouched apart
+ * from the tab conversion, exactly as before. */
zend_string *zend_markup_normalize_text(const char *text, size_t len)
{
- zend_markup_line stackbuf[32];
- zend_markup_line *lines = stackbuf;
- uint32_t cap = 32, count = 0;
+ /* Output is never longer than the input. */
+ zend_string *result = zend_string_alloc(len, 0);
+ char *out = ZSTR_VAL(result);
+ size_t outlen = 0;
size_t pos = 0;
+ bool pending_join = false;
- /* Split on \r\n | \n | \r (matching JS String.split), keeping a trailing
- * empty line after a final break so the previous line counts as "not last". */
+ /* Line terminators are \r\n | \n | \r (matching JS String.split). A line
+ * is "last" when no terminator follows it, so text ending in a line break
+ * still right-trims its final content line, as the split-based version did
+ * via its synthetic trailing empty line. */
for (;;) {
size_t e = pos;
while (e < len && text[e] != '\n' && text[e] != '\r') {
e++;
}
- if (count == cap) {
- lines = zend_markup_lines_grow(lines, stackbuf, count, &cap);
- }
- lines[count].p = text + pos;
- lines[count].n = e - pos;
- count++;
-
- if (e >= len) {
- break;
- }
- if (text[e] == '\r' && e + 1 < len && text[e + 1] == '\n') {
- e++;
- }
- pos = e + 1;
- if (pos == len) {
- if (count == cap) {
- lines = zend_markup_lines_grow(lines, stackbuf, count, &cap);
- }
- lines[count].p = text + len;
- lines[count].n = 0;
- count++;
- break;
- }
- }
-
- /* Index of the last line containing a non-space/tab char (default 0, as in Babel). */
- uint32_t last_nonblank = 0;
- for (uint32_t k = 0; k < count; k++) {
- for (size_t x = 0; x < lines[k].n; x++) {
- if (lines[k].p[x] != ' ' && lines[k].p[x] != '\t') {
- last_nonblank = k;
- break;
- }
- }
- }
-
- /* Output is never longer than the input. */
- zend_string *result = zend_string_alloc(len, 0);
- char *out = ZSTR_VAL(result);
- size_t outlen = 0;
-
- for (uint32_t k = 0; k < count; k++) {
- const char *p = lines[k].p;
- size_t s = 0, en = lines[k].n;
- bool is_first = (k == 0);
- bool is_last = (k == count - 1);
+ bool is_first = (pos == 0);
+ bool is_last = (e >= len);
+ size_t s = pos, en = e;
if (!is_first) {
- while (s < en && (p[s] == ' ' || p[s] == '\t')) s++;
+ while (s < en && (text[s] == ' ' || text[s] == '\t')) s++;
}
if (!is_last) {
- while (en > s && (p[en - 1] == ' ' || p[en - 1] == '\t')) en--;
+ while (en > s && (text[en - 1] == ' ' || text[en - 1] == '\t')) en--;
}
if (en > s) {
- for (size_t x = s; x < en; x++) {
- out[outlen++] = (p[x] == '\t') ? ' ' : p[x];
- }
- if (k != last_nonblank) {
+ if (pending_join) {
out[outlen++] = ' ';
}
+ for (size_t x = s; x < en; x++) {
+ out[outlen++] = (text[x] == '\t') ? ' ' : text[x];
+ }
+ pending_join = true;
}
- }
- if (lines != stackbuf) {
- efree(lines);
+ if (is_last) {
+ break;
+ }
+ if (text[e] == '\r' && e + 1 < len && text[e + 1] == '\n') {
+ e++;
+ }
+ pos = e + 1;
}
if (outlen == 0) {
From 12703ab402937f904de3ae5cecf54257a8afa657 Mon Sep 17 00:00:00 2001
From: Liam Hammett
Date: Thu, 9 Jul 2026 23:30:35 +0100
Subject: [PATCH 11/17] Reuse label for markup expressions in the scanner
---
Zend/zend_language_scanner.l | 80 ++++++++++++------------------------
1 file changed, 26 insertions(+), 54 deletions(-)
diff --git a/Zend/zend_language_scanner.l b/Zend/zend_language_scanner.l
index 26a6183326dc..00ce67b8930d 100644
--- a/Zend/zend_language_scanner.l
+++ b/Zend/zend_language_scanner.l
@@ -1447,6 +1447,9 @@ HNUM "0x"[0-9a-fA-F]+(_[0-9a-fA-F]+)*
BNUM "0b"[01]+(_[01]+)*
ONUM "0o"[0-7]+(_[0-7]+)*
LABEL [a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*
+/* Native markup: one segment of a tag or attribute name. Dots,
+ * colons and hyphens are allowed inside (, wire:poll.1s). */
+MARKUP_LABEL [a-zA-Z][a-zA-Z0-9_.:-]*
WHITESPACE [ \n\r\t]+
TABS_AND_SPACES [ \t]*
TOKENS [;:,.|^&+-/*=%!~$<>?@]
@@ -2048,53 +2051,22 @@ OPTIONAL_WHITESPACE_OR_COMMENTS ({WHITESPACE}|{MULTI_LINE_COMMENT}|{SINGLE_LINE_
}
-/* Native markup (RFC: Native Markup Expressions). In operand position, "" a fragment; in operator position both keep
- * their existing meaning ("<" comparison, "<>" not-equal). Nested elements
- * inside markup content use a bare "<" handled by ST_MARKUP_CONTENT. */
-"<""\\"?[a-zA-Z] {
- /* ""<>" {
+/* Native markup (RFC: Native Markup Expressions). In operand position, "<"
+ * followed by a tag opener begins markup: "" (fragment), "<$var" / "<{expr}" (dynamic tags). In operator
+ * position everything keeps its existing meaning: "<" a comparison ("$a <$b",
+ * "$a < \Foo::BAR") and "<>" the legacy not-equal alias. The opener set here
+ * is mirrored by ST_MARKUP_CONTENT's nested-tag rule below. */
+"<"("\\"?[a-zA-Z]|[>${]) {
if (zend_markup_operand_position()) {
yyless(1);
yy_push_state(ST_MARKUP_TAG);
RETURN_TOKEN(T_MARKUP_OPEN);
}
- RETURN_TOKEN(T_IS_NOT_EQUAL);
-}
-
-"<""$"{LABEL} {
- /* "<$tag": a dynamic tag whose variable's runtime value names the element
- * or component (<$tag>…$tag>). Only markup in operand position; in
- * operator position "<" keeps its comparison meaning ("$a <$b"). */
- yyless(1);
- if (zend_markup_operand_position()) {
- yy_push_state(ST_MARKUP_TAG);
- RETURN_TOKEN(T_MARKUP_OPEN);
+ if (yytext[1] == '>') {
+ RETURN_TOKEN(T_IS_NOT_EQUAL);
}
- RETURN_TOKEN('<');
-}
-
-"<{" {
- /* "<{expr}": a dynamic tag computed by an arbitrary expression, closed
- * anonymously (">") or self-closed. The "{" is left for ST_MARKUP_TAG's
- * own interpolation rule. Only markup in operand position; in operator
- * position "<" keeps its comparison meaning. */
yyless(1);
- if (zend_markup_operand_position()) {
- yy_push_state(ST_MARKUP_TAG);
- RETURN_TOKEN(T_MARKUP_OPEN);
- }
RETURN_TOKEN('<');
}
@@ -2105,14 +2077,14 @@ OPTIONAL_WHITESPACE_OR_COMMENTS ({WHITESPACE}|{MULTI_LINE_COMMENT}|{SINGLE_LINE_
goto return_whitespace;
}
-"\\"?[a-zA-Z][a-zA-Z0-9_.:-]*("\\"[a-zA-Z][a-zA-Z0-9_.:-]*)* {
+"\\"?{MARKUP_LABEL}("\\"{MARKUP_LABEL})* {
/* A tag name, optionally namespace-qualified () or fully
* qualified (<\App\Card/>). A name containing "\" always dispatches as a
* component (see zend_markup_name_is_component in Zend/zend_markup.c). */
RETURN_TOKEN_WITH_STR(T_MARKUP_NAME, 0);
}
-[:@][a-zA-Z][a-zA-Z0-9_.:-]* {
+[:@]{MARKUP_LABEL} {
/* An attribute name with a ":" or "@" shorthand prefix (Vue/Alpine:
* :class, @click). Attribute position only: this can never lex as a tag
* name, because every markup-open rule requires a letter, "\", "$", "{"
@@ -2218,19 +2190,19 @@ OPTIONAL_WHITESPACE_OR_COMMENTS ({WHITESPACE}|{MULTI_LINE_COMMENT}|{SINGLE_LINE_
RETURN_TOKEN(T_MARKUP_CLOSE_OPEN);
}
-"<" {
+"<"("\\"?[a-zA-Z]|[>${]) {
/* A nested tag ("
"); "" and "
one
)->__toString(), "\n";
+
+// --- operand positions: markup lexes after every operand-expecting token ---
+// (the scanner's allowlist; a comparison never silently changes meaning)
+echo "concat " . c, "\n"; // after "."
+echo true ? t : f, "\n"; // after "?" and ":"
+echo null ?? coal, "\n"; // after "??"
+echo (string)
cast
, "\n"; // after a cast
+echo
pipe
|> strtoupper(...), "\n"; // after "|>"
+echo clone cl, "\n"; // after `clone`
+print
print
; echo "\n"; // after `print`
+echo [a, b][1], "\n"; // after "[" and ","
+echo (fn() =>
arrow
)(), "\n"; // after "=>"
+echo match(true) { true => match }, "\n"; // match arm "=>"
+function gen() { yield from [yf]; } // after `yield from`
+echo iterator_to_array(gen())[0], "\n";
?>
--EXPECT--
bool(true)
@@ -148,3 +163,14 @@ string(0) ""
ab
xy
one
+concat c
+t
+coal
+
cast
+
PIPE
+cl
+
print
+b
+
arrow
+match
+yf
From df72032c913adae217da093770405ee5daa48c49 Mon Sep 17 00:00:00 2001
From: Liam Hammett
Date: Fri, 10 Jul 2026 00:24:50 +0100
Subject: [PATCH 14/17] Refactor __toString() methods to share common logic
---
ext/html/html.c | 49 +++++++++++++++++++------------------------------
1 file changed, 19 insertions(+), 30 deletions(-)
diff --git a/ext/html/html.c b/ext/html/html.c
index 3e6a24d5b24e..7f6f7a8b59bf 100644
--- a/ext/html/html.c
+++ b/ext/html/html.c
@@ -759,16 +759,23 @@ PHP_METHOD(Html_Raw, toHtml)
RETURN_COPY(ZEND_THIS);
}
-PHP_METHOD(Html_Element, __toString)
+/* Shared __toString() body for the three node classes: serialize through
+ * html_render_node, the same function child-position rendering uses, so
+ * `(string) $el` and rendering $el as a child can never diverge. */
+static void html_node_tostring(INTERNAL_FUNCTION_PARAMETERS)
{
ZEND_PARSE_PARAMETERS_NONE();
- smart_str buf = {0};
- if (html_render_element(&buf, Z_OBJ_P(ZEND_THIS)) == FAILURE) {
- smart_str_free(&buf);
+ zend_string *s = html_render_node(ZEND_THIS);
+ if (s == NULL) {
RETURN_THROWS();
}
- RETURN_STR(smart_str_extract(&buf));
+ RETURN_STR(s);
+}
+
+PHP_METHOD(Html_Element, __toString)
+{
+ html_node_tostring(INTERNAL_FUNCTION_PARAM_PASSTHRU);
}
PHP_METHOD(Html_Fragment, __construct)
@@ -785,14 +792,7 @@ PHP_METHOD(Html_Fragment, __construct)
PHP_METHOD(Html_Fragment, __toString)
{
- ZEND_PARSE_PARAMETERS_NONE();
-
- smart_str buf = {0};
- if (html_render_fragment(&buf, Z_OBJ_P(ZEND_THIS)) == FAILURE) {
- smart_str_free(&buf);
- RETURN_THROWS();
- }
- RETURN_STR(smart_str_extract(&buf));
+ html_node_tostring(INTERNAL_FUNCTION_PARAM_PASSTHRU);
}
PHP_METHOD(Html_Raw, __construct)
@@ -808,16 +808,7 @@ PHP_METHOD(Html_Raw, __construct)
PHP_METHOD(Html_Raw, __toString)
{
- ZEND_PARSE_PARAMETERS_NONE();
-
- zval rv;
- zval *html = zend_read_property(html_ce_Raw, Z_OBJ_P(ZEND_THIS), ZEND_STRL("html"), true, &rv);
- ZVAL_DEREF(html);
- if (Z_TYPE_P(html) != IS_STRING) {
- zend_throw_error(NULL, "Html\\Raw has not been initialized");
- RETURN_THROWS();
- }
- RETURN_STR(zend_string_copy(Z_STR_P(html)));
+ html_node_tostring(INTERNAL_FUNCTION_PARAM_PASSTHRU);
}
PHP_METHOD(Html_LazyFragment, __construct)
@@ -887,13 +878,11 @@ PHP_METHOD(Html_LazyFragment, toHtml)
PHP_METHOD(Html_LazyFragment, __toString)
{
- ZEND_PARSE_PARAMETERS_NONE();
-
- zend_string *s = html_render_htmlable(ZEND_THIS);
- if (s == NULL) {
- RETURN_THROWS();
- }
- RETURN_STR(s);
+ /* Identical to the default __toString() injected into userland Htmlable
+ * classes: resolve through toHtml() (running/memoizing the thunk), then
+ * serialize. Declared explicitly because the injection skips internal
+ * classes (see html_implement_htmlable). */
+ html_htmlable_default_tostring(INTERNAL_FUNCTION_PARAM_PASSTHRU);
}
/* Build an Html\Raw wrapping the given (already-final) HTML string. */
From 18ed64cb44b19aeff5d5acd907f3bb499b50403f Mon Sep 17 00:00:00 2001
From: Liam Hammett
Date: Fri, 10 Jul 2026 00:28:06 +0100
Subject: [PATCH 15/17] Refactor attribute handling in html_append_attribute to
use a dedicated function for value emission
---
ext/html/html.c | 56 ++++++++++++++++++++++---------------------------
1 file changed, 25 insertions(+), 31 deletions(-)
diff --git a/ext/html/html.c b/ext/html/html.c
index 7f6f7a8b59bf..c3182cb17850 100644
--- a/ext/html/html.c
+++ b/ext/html/html.c
@@ -344,6 +344,25 @@ static zend_result html_append_child(smart_str *buf, zval *value, uint32_t depth
}
}
+/* Emit ` name="value"` into the buffer and release `value`. The `escape` flag
+ * is the one security-relevant decision in attribute emission: every value is
+ * escaped except an Htmlable's already-safe HTML (and numbers, which cannot
+ * contain escapable characters). Values are always serialized double-quoted —
+ * no unquoted output form exists. */
+static void html_append_attr_value(smart_str *buf, zend_string *name, zend_string *value, bool escape)
+{
+ smart_str_appendc(buf, ' ');
+ smart_str_append(buf, name);
+ smart_str_appendl(buf, "=\"", 2);
+ if (escape) {
+ html_append_escaped(buf, value);
+ } else {
+ smart_str_append(buf, value);
+ }
+ smart_str_appendc(buf, '"');
+ zend_string_release(value);
+}
+
/* Coerce and append a single attribute value (RFC §5 attribute coercion). */
static zend_result html_append_attribute(smart_str *buf, zend_string *name, zval *value)
{
@@ -360,30 +379,15 @@ static zend_result html_append_attribute(smart_str *buf, zend_string *name, zval
return SUCCESS;
case IS_LONG:
- smart_str_appendc(buf, ' ');
- smart_str_append(buf, name);
- smart_str_appendl(buf, "=\"", 2);
- smart_str_append_long(buf, Z_LVAL_P(value));
- smart_str_appendc(buf, '"');
+ html_append_attr_value(buf, name, zend_long_to_str(Z_LVAL_P(value)), false);
return SUCCESS;
- case IS_DOUBLE: {
- zend_string *s = zend_double_to_str(Z_DVAL_P(value));
- smart_str_appendc(buf, ' ');
- smart_str_append(buf, name);
- smart_str_appendl(buf, "=\"", 2);
- smart_str_append(buf, s);
- smart_str_appendc(buf, '"');
- zend_string_release(s);
+ case IS_DOUBLE:
+ html_append_attr_value(buf, name, zend_double_to_str(Z_DVAL_P(value)), false);
return SUCCESS;
- }
case IS_STRING:
- smart_str_appendc(buf, ' ');
- smart_str_append(buf, name);
- smart_str_appendl(buf, "=\"", 2);
- html_append_escaped(buf, Z_STR_P(value));
- smart_str_appendc(buf, '"');
+ html_append_attr_value(buf, name, zend_string_copy(Z_STR_P(value)), true);
return SUCCESS;
case IS_OBJECT: {
@@ -399,12 +403,7 @@ static zend_result html_append_attribute(smart_str *buf, zend_string *name, zval
if (!s) {
return FAILURE;
}
- smart_str_appendc(buf, ' ');
- smart_str_append(buf, name);
- smart_str_appendl(buf, "=\"", 2);
- smart_str_append(buf, s);
- smart_str_appendc(buf, '"');
- zend_string_release(s);
+ html_append_attr_value(buf, name, s, false);
return SUCCESS;
}
@@ -413,12 +412,7 @@ static zend_result html_append_attribute(smart_str *buf, zend_string *name, zval
if (!s) {
return FAILURE;
}
- smart_str_appendc(buf, ' ');
- smart_str_append(buf, name);
- smart_str_appendl(buf, "=\"", 2);
- html_append_escaped(buf, s);
- smart_str_appendc(buf, '"');
- zend_string_release(s);
+ html_append_attr_value(buf, name, s, true);
return SUCCESS;
}
ZEND_FALLTHROUGH;
From 79e4190a9a6f255532d0c285af98c41e5174fca6 Mon Sep 17 00:00:00 2001
From: Liam Hammett
Date: Fri, 10 Jul 2026 00:39:35 +0100
Subject: [PATCH 16/17] Validate component prop keys in a single pass
---
ext/html/html.c | 29 +++++++++++++----------------
1 file changed, 13 insertions(+), 16 deletions(-)
diff --git a/ext/html/html.c b/ext/html/html.c
index c3182cb17850..97bd4bfb4cdd 100644
--- a/ext/html/html.c
+++ b/ext/html/html.c
@@ -1395,21 +1395,6 @@ static void html_render_component_impl(
zend_string *component, HashTable *props, zend_object *slot_obj,
zval *return_value)
{
- /* Props must be string-keyed, whichever dispatch path runs below. (On the
- * borrowed fast path, an integer key would silently become a positional
- * argument in zend_call_known_function.) */
- if (props != NULL && zend_hash_num_elements(props) > 0) {
- zend_string *key;
- zval *val;
- ZEND_HASH_FOREACH_STR_KEY_VAL(props, key, val) {
- (void) val;
- if (key == NULL) {
- zend_throw_error(NULL, "Component props must use string keys");
- RETURN_THROWS();
- }
- } ZEND_HASH_FOREACH_END();
- }
-
/* Resolve the component to a dispatch target (RFC §4). This is the second
* stage of a two-stage resolution: the compiler already resolved the *name*
* against use/namespace (see zend_ast_create_markup_component in
@@ -1452,11 +1437,23 @@ static void html_render_component_impl(
/* Fast path: with no slot content to route and no #[Html\Slot] parameters to
* inspect, the named-argument array is exactly $props - pass it through
- * directly (borrowed) instead of allocating and copying a new HashTable. */
+ * directly (borrowed) instead of allocating and copying a new HashTable.
+ * Each path validates prop keys exactly once: here on the borrowed table
+ * (an integer key would silently become a positional argument in
+ * zend_call_known_function), or in html_route_component_args as it copies. */
HashTable *args;
bool owns_args;
if (slot_ptr == NULL
&& (fn == NULL || fn->common.attributes == NULL)) {
+ if (props != NULL) {
+ zend_string *key;
+ ZEND_HASH_FOREACH_STR_KEY(props, key) {
+ if (key == NULL) {
+ zend_throw_error(NULL, "Component props must use string keys");
+ RETURN_THROWS();
+ }
+ } ZEND_HASH_FOREACH_END();
+ }
args = props; /* may be NULL when no attributes were given */
owns_args = false;
} else {
From 768dc33af4677113a8fc33953a589c54040427ca Mon Sep 17 00:00:00 2001
From: Liam Hammett
Date: Fri, 10 Jul 2026 00:53:24 +0100
Subject: [PATCH 17/17] Remove RFC references
---
Zend/zend_language_scanner.l | 4 +-
Zend/zend_markup.c | 45 +++++++++----------
Zend/zend_markup.h | 6 +--
Zend/zend_markup_entities.h | 2 +-
Zend/zend_markup_entities_gen.php | 14 +++---
ext/html/html.c | 32 ++++++-------
ext/html/html.stub.php | 28 ++++++------
ext/html/html_arginfo.h | 2 +-
ext/html/php_html.h | 6 +--
ext/html/tests/children.phpt | 2 +-
ext/html/tests/component_hooks.phpt | 4 +-
ext/html/tests/component_slots.phpt | 2 +-
ext/html/tests/components.phpt | 2 +-
ext/html/tests/element.phpt | 2 +-
ext/html/tests/htmlable.phpt | 2 +-
ext/html/tests/lazy_fragment.phpt | 2 +-
ext/html/tests/syntax_attributes.phpt | 2 +-
ext/html/tests/syntax_basic.phpt | 2 +-
.../tests/syntax_component_resolution.phpt | 4 +-
ext/html/tests/syntax_doctype.phpt | 2 +-
ext/html/tests/syntax_dynamic.phpt | 2 +-
ext/html/tests/syntax_entities.phpt | 2 +-
ext/html/tests/syntax_lowering.phpt | 5 +--
ext/html/tests/syntax_mismatch.phpt | 2 +-
24 files changed, 87 insertions(+), 89 deletions(-)
diff --git a/Zend/zend_language_scanner.l b/Zend/zend_language_scanner.l
index 8fb45ed81661..0bbaaedf254a 100644
--- a/Zend/zend_language_scanner.l
+++ b/Zend/zend_language_scanner.l
@@ -2192,7 +2192,7 @@ OPTIONAL_WHITESPACE_OR_COMMENTS ({WHITESPACE}|{MULTI_LINE_COMMENT}|{SINGLE_LINE_
}
"";
+ /* An HTML comment, emitted verbatim. Scan to the closing "-->";
* content is literal (no interpolation). */
while (YYCURSOR < YYLIMIT) {
if (YYCURSOR + 2 < YYLIMIT && YYCURSOR[0] == '-' && YYCURSOR[1] == '-' && YYCURSOR[2] == '>') {
@@ -2207,7 +2207,7 @@ OPTIONAL_WHITESPACE_OR_COMMENTS ({WHITESPACE}|{MULTI_LINE_COMMENT}|{SINGLE_LINE_
}
"";
* content is literal (no interpolation). */
while (YYCURSOR < YYLIMIT) {
diff --git a/Zend/zend_markup.c b/Zend/zend_markup.c
index ec7792ea0926..01f0014f6294 100644
--- a/Zend/zend_markup.c
+++ b/Zend/zend_markup.c
@@ -15,7 +15,7 @@
+----------------------------------------------------------------------+
*/
-/* Native markup (RFC: Native Markup Expressions) compile-time support:
+/* Native Markup Expressions compile-time support:
*
* - text normalization and character-reference decoding, called by the
* lexer (Zend/zend_language_scanner.l) on markup text and literal
@@ -36,7 +36,7 @@
#include "zend_markup.h"
#include "zend_markup_entities.h"
-/* Native markup whitespace normalization (RFC §7), following the JSX/Babel rules:
+/* Native markup whitespace normalization, following the JSX/Babel rules:
* - split the text node into lines;
* - trim leading whitespace on every line but the first, and trailing
* whitespace on every line but the last;
@@ -44,12 +44,12 @@
* - join the surviving lines with a single space.
* The net effect: indentation/newlines between block elements vanish, while a
* meaningful single space between inline content is preserved. Returns a new
- * zend_string (possibly empty — the caller drops empty text nodes).
+ * zend_string (possibly empty - the caller drops empty text nodes).
*
* One streaming pass: because a non-first line is left-trimmed and a non-last
* line is right-trimmed, any line that survives contains a non-whitespace
* character, so "join surviving lines with a single space" is simply a space
- * before every surviving line except the first — no line table or lookahead
+ * before every surviving line except the first - no line table or lookahead
* for the last non-blank line is needed. A single-line text is untouched apart
* from the tab conversion, exactly as before. */
zend_string *zend_markup_normalize_text(const char *text, size_t len)
@@ -108,13 +108,13 @@ zend_string *zend_markup_normalize_text(const char *text, size_t len)
return result;
}
-/* Native markup character references (RFC §7). Markup text and literal
+/* Native markup character references. Markup text and literal
* attribute values decode HTML character references at compile time: the
* frozen WHATWG named set (semicolon-terminated forms only; the standard
* guarantees the list will never grow) plus numeric { / 😀 forms.
* Decoding is lenient like HTML and JSX: anything that does not parse as a
- * reference — a bare "&", an unknown name, an out-of-range or surrogate
- * codepoint — stays literal text, so "fish & chips" needs no escaping.
+ * reference - a bare "&", an unknown name, an out-of-range or surrogate
+ * codepoint - stays literal text, so "fish & chips" needs no escaping.
* Decoded text is a plain string escaped at render time, so "&" in source
* round-trips to "&" in output. Text decodes after whitespace
* normalization, so " " survives it (the entity spelling of JSX's {' '}). */
@@ -256,7 +256,7 @@ zend_string *zend_markup_decode_entities(zend_string *str)
return smart_str_extract(&buf);
}
-/* Native markup AST lowering (RFC: Native Markup Expressions, Phase 2/3).
+/* Native markup AST lowering.
* Lower a markup element into an ordinary `new \Html\Element(tag, [], [children])`
* (or `new \Html\Fragment([children])` when the tag name is empty) so no new AST
* node kind or zend_compile.c support is needed. `name` is the tag-name zval AST
@@ -273,7 +273,7 @@ static zend_ast *zend_ast_markup_fq_name(const char *name, size_t len)
return ast;
}
-/* Element vs component classification (RFC §4): a tag whose first character is
+/* Element vs component classification: a tag whose first character is
* uppercase, which is namespace-qualified (contains "\"), or which names a
* static method via "::", dispatches as a component; anything else is a literal
* HTML element. The single home for this rule: the compiler applies it to
@@ -302,7 +302,7 @@ static zend_ast *zend_ast_wrap_fragment(zend_ast *children)
}
/* Wrap a string AST in a `\Html\raw(...)` call (trusted passthrough). Used for
- * `` comments, which are emitted as literal HTML (RFC §7). */
+ * `` comments, which are emitted as literal HTML. */
zend_ast *zend_ast_create_markup_raw(zend_ast *str)
{
zend_ast *fn = zend_ast_markup_fq_name(ZEND_STRL(ZEND_MARKUP_RAW_FQ));
@@ -311,16 +311,16 @@ zend_ast *zend_ast_create_markup_raw(zend_ast *str)
}
/* Wrap a children list in
- * `new \Html\LazyFragment(fn() => new \Html\Fragment([children]))` (RFC §5, the
- * `:lazy` directive). The arrow function defers building the child subtree —
- * and running any side effects in its interpolations — until the component
+ * `new \Html\LazyFragment(fn() => new \Html\Fragment([children]))` (the
+ * `:lazy` directive). The arrow function defers building the child subtree -
+ * and running any side effects in its interpolations - until the component
* actually renders the slot, so a component that discards its body never
* evaluates it. Consumes `children`. */
static zend_ast *zend_ast_wrap_lazy_fragment(zend_ast *children, uint32_t lineno)
{
zend_ast *frag = zend_ast_wrap_fragment(children);
- /* fn() => new \Html\Fragment([...]) — an arrow function so its body
+ /* fn() => new \Html\Fragment([...]) - an arrow function so its body
* auto-captures by value whatever it references, exactly as a hand-written
* one would; only the expression evaluation is deferred. */
zend_ast *params = zend_ast_create_list(0, ZEND_AST_PARAM_LIST);
@@ -362,24 +362,24 @@ static bool zend_markup_extract_lazy(zend_ast *attrs)
/* Lower a component tag (a capitalized or namespace-qualified name, or
* "Class::method") into a call to \Html\render_component(component, props, slot)
- * (RFC §3). Attributes become the props array and the body content becomes a
+ * Attributes become the props array and the body content becomes a
* single \Html\Fragment passed as the slot (or a lazy \Html\LazyFragment when
* the `:lazy` directive is present).
*
* Component resolution is two-stage, and every tag resolves through the *class*
* name rules only: a bare/qualified tag lowers to ZEND_AST_CLASS_NAME (the
* `Component::class` machinery), and a "Class::method" tag lowers the class
- * part the same way with "::method" appended — both honor `use` imports and
+ * part the same way with "::method" appended - both honor `use` imports and
* the current namespace, with a leading "\" fully qualified. The runtime then
* resolves the name to a class implementing Html\Htmlable (instantiated) or a
- * public static method (called) — see PHP_FUNCTION(Html_render_component) in
+ * public static method (called) - see PHP_FUNCTION(Html_render_component) in
* ext/html/html.c.
*
* Plain *function* components are deliberately out of v1: a bare tag gives no
* signal whether a class or a function is meant, and functions resolve through
* the separate `use function` import table, so supporting them would need a
* second compile-time resolution and a new AST kind to carry it. A static
- * method has no such ambiguity — its class part resolves like any class. */
+ * method has no such ambiguity - its class part resolves like any class. */
static zend_ast *zend_ast_create_markup_component(zend_ast *name, zend_ast *attrs, zend_ast *children)
{
uint32_t lineno = zend_ast_get_lineno(name);
@@ -415,7 +415,7 @@ static zend_ast *zend_ast_create_markup_component(zend_ast *name, zend_ast *attr
}
attrs->attr = ZEND_ARRAY_SYNTAX_SHORT;
- /* The `:lazy` directive is a compiler marker, not a prop — strip it first. */
+ /* The `:lazy` directive is a compiler marker, not a prop - strip it first. */
bool lazy = zend_markup_extract_lazy(attrs);
/* The body becomes a single slot Fragment, or null when there is no body.
@@ -465,7 +465,7 @@ zend_ast *zend_ast_create_markup_element(zend_ast *name, zend_ast *attrs, zend_a
? zend_ast_get_lineno(name) : zend_ast_get_lineno(children);
/* A capitalized name, a namespace-qualified name, or one containing "::"
- * is a component (RFC §3). */
+ * is a component. */
if (name != NULL && zend_markup_name_is_component(zend_ast_get_str(name))) {
result = zend_ast_create_markup_component(name, attrs, children);
result->lineno = lineno;
@@ -500,7 +500,7 @@ zend_ast *zend_ast_create_markup_element(zend_ast *name, zend_ast *attrs, zend_a
}
/* Build a markup element (or, with `dynamic`, a dynamic tag) after checking
- * the closing tag matches the opener (RFC §7): `
…` and
+ * the closing tag matches the opener: `
…` and
* `<$a>…$b>` are compile errors alike. Both `open` and `close` NULL means a
* `<>…>` fragment. `close` is consumed. */
zend_ast *zend_ast_create_markup_checked(zend_ast *open, zend_ast *attrs, zend_ast *children, zend_ast *close, bool dynamic)
@@ -568,7 +568,7 @@ static zend_ast *zend_markup_dynamic_call(zend_ast *tag_expr, zend_ast *attrs, z
return result;
}
-/* Lower a dynamic tag `<$tag …>…$tag>` (RFC §4) into a call to
+/* Lower a dynamic tag `<$tag …>…$tag>` into a call to
* \Html\render_dynamic($tag, attributes, children). The variable's runtime
* value decides what a static tag name decides at compile time, by the same
* classification rule (zend_markup_name_is_component); only classification
@@ -592,4 +592,3 @@ zend_ast *zend_ast_create_markup_dynamic_expr(zend_ast *expr, zend_ast *attrs, z
{
return zend_markup_dynamic_call(expr, attrs, children, zend_ast_get_lineno(expr));
}
-
diff --git a/Zend/zend_markup.h b/Zend/zend_markup.h
index ced15562486e..35644855295c 100644
--- a/Zend/zend_markup.h
+++ b/Zend/zend_markup.h
@@ -20,7 +20,7 @@
#include "zend_ast.h"
-/* The fully-qualified names that native markup (RFC: Native Markup Expressions)
+/* The fully-qualified names that Native Markup Expressions
* lowers into. This is the single home for the markup-to-runtime vocabulary:
* the parser (Zend/zend_language_parser.y) lowers markup expressions to exactly
* these symbols, and the ext/html runtime registers exactly them (asserted at
@@ -40,9 +40,9 @@
BEGIN_EXTERN_C()
-/* Element vs component classification (RFC §4): uppercase first character,
+/* Element vs component classification: uppercase first character,
* a "\", or a "::" dispatches as a component; anything else is a literal HTML
- * element. The single home for this rule — the compiler applies it to static
+ * element. The single home for this rule - the compiler applies it to static
* tag names, and Html\render_dynamic applies it to a dynamic tag's runtime
* value, so `<$tag>` decides at runtime exactly what `` decides at
* compile time. */
diff --git a/Zend/zend_markup_entities.h b/Zend/zend_markup_entities.h
index 2433f54f3b96..8611ee080df5 100644
--- a/Zend/zend_markup_entities.h
+++ b/Zend/zend_markup_entities.h
@@ -1,4 +1,4 @@
-/* This is a generated file — edit Zend/zend_markup_entities_gen.php instead.
+/* This is a generated file - edit Zend/zend_markup_entities_gen.php instead.
* Source data: ext/standard/html_tables/ents_html5.txt (the WHATWG HTML5
* named-reference table, frozen by the HTML standard; also feeds
* html_entity_decode()). Canonical semicolon-terminated forms only; sorted
diff --git a/Zend/zend_markup_entities_gen.php b/Zend/zend_markup_entities_gen.php
index 23f3b57bcef5..d9efd2f9dd07 100644
--- a/Zend/zend_markup_entities_gen.php
+++ b/Zend/zend_markup_entities_gen.php
@@ -1,14 +1,15 @@
Zend/zend_markup_entities.h
*
- * The source data is ext/standard/html_tables/ents_html5.txt — the same
+ * The source data is ext/standard/html_tables/ents_html5.txt - the same
* WHATWG HTML5 named-reference table that feeds html_entity_decode(), so
* there is a single copy of the data in the tree. The HTML standard
- * guarantees this list is frozen — no named entity will ever be added — so
+ * guarantees this list is frozen - no named entity will ever be added - so
* the generated header only changes if this generator does. The table
* contains only the canonical semicolon-terminated forms: markup requires
* the well-formed "&name;" spelling and treats legacy forms like "&"
@@ -23,7 +24,8 @@
exit(1);
}
-function utf8_bytes(int $cp): string {
+function utf8_bytes(int $cp): string
+{
if ($cp < 0x80) {
return chr($cp);
} elseif ($cp < 0x800) {
@@ -66,7 +68,7 @@ function utf8_bytes(int $cp): string {
$count = count($entities);
echo <<…$tag>` (RFC §4). The value
+/* The runtime target of a dynamic tag `<$tag …>…$tag>`. The value
* decides at runtime exactly what a static tag name decides at compile time,
* by the same classification rule (zend_markup_name_is_component): an
- * uppercase first character, a "\", or a "::" dispatches as a component —
- * through the full render_component machinery, hooks and decorators included —
+ * uppercase first character, a "\", or a "::" dispatches as a component -
+ * through the full render_component machinery, hooks and decorators included -
* and anything else constructs a literal \Html\Element. Exposed as a public
* function like render_component, so the rule is directly testable. */
PHP_FUNCTION(Html_render_dynamic)
diff --git a/ext/html/html.stub.php b/ext/html/html.stub.php
index 0bd0bf2cf3bd..61bfb8adcf19 100644
--- a/ext/html/html.stub.php
+++ b/ext/html/html.stub.php
@@ -88,13 +88,11 @@ public function __toString(): string {}
* (e.g. `Title}>…`).
*/
#[\Attribute(\Attribute::TARGET_PARAMETER)]
- final class Slot
- {
- }
+ final class Slot {}
/**
- * A slot body whose evaluation is deferred: it builds the child subtree —
- * and runs any side effects in its interpolations — only the first time it
+ * A slot body whose evaluation is deferred: it builds the child subtree -
+ * and runs any side effects in its interpolations - only the first time it
* is rendered, memoizing the result. Produced by the `:lazy` directive on a
* component tag (`…`), so a component that discards its
* body never evaluates it. Because it is itself an Html\Htmlable, a
@@ -127,19 +125,19 @@ function raw(string $html): \Html\Htmlable {}
function escape(string $text): \Html\Htmlable {}
/**
- * Dispatches a component (RFC §3 and §5) and returns the Html\Htmlable it
+ * Dispatches a component and returns the Html\Htmlable it
* produces. This is the runtime target a `` markup tag lowers
* into; it is exposed as a public function so the dispatch and slot-routing
* rules are directly testable, but user code is expected to write tags.
*
* A component is a class implementing Html\Htmlable, or a public static
* method on a class ("Class::method"). $component is the already-resolved
- * name (the compiler resolves the tag — the class part of a "::" tag
- * included — against `use` imports and the current namespace, as
+ * name (the compiler resolves the tag - the class part of a "::" tag
+ * included - against `use` imports and the current namespace, as
* `Component::class` does). A class name is looked up, confirmed to
* implement Html\Htmlable, and instantiated; a "Class::method" name is
- * called and must return an Html\Htmlable. Anything else — no such class,
- * a class not implementing the interface, a non-public/non-static method —
+ * called and must return an Html\Htmlable. Anything else - no such class,
+ * a class not implementing the interface, a non-public/non-static method -
* is a hard error. Plain functions are not components (Future Scope).
*
* $props become named arguments. The body $slot is routed to the parameter
@@ -156,7 +154,7 @@ function render_component(
): \Html\Htmlable {}
/**
- * Renders a dynamic tag (RFC §4): the runtime target a `<$tag …>…$tag>`
+ * Renders a dynamic tag: the runtime target a `<$tag …>…$tag>`
* or `<{expr} …>…>` markup tag lowers into. $tag's value decides at runtime exactly what a
* static tag name decides at compile time, by the same classification
* rule: a name with an uppercase first character, a "\", or a "::"
@@ -193,7 +191,7 @@ function render_dynamic(
* Passing $component (a class FQCN; case-insensitive, with or without a
* leading backslash) scopes the factory to that one component: it only runs
* when the resolved class name matches, and every other class component
- * skips it entirely — no userland call is made. Scoped and unscoped
+ * skips it entirely - no userland call is made. Scoped and unscoped
* factories share one chain, still tried in registration order.
*
* Example (Laravel):
@@ -219,7 +217,7 @@ function unregister_component_factory(callable $factory, ?string $component = nu
/**
* Register a decorator that runs on the Html\Htmlable every component
- * produces — class and static-method alike — after it has been constructed
+ * produces - class and static-method alike - after it has been constructed
* or called. This is the cross-cutting seam (as opposed to the factory
* production seam): use it to wrap, transform, log, profile, or cache
* component output uniformly.
@@ -231,8 +229,8 @@ function unregister_component_factory(callable $factory, ?string $component = nu
* previous one's result. Registration is request-scoped.
*
* Passing $component scopes the decorator to that one component: it only
- * runs when the resolved component name — the same name the decorator
- * receives — matches (case-insensitive, with or without a leading
+ * runs when the resolved component name - the same name the decorator
+ * receives - matches (case-insensitive, with or without a leading
* backslash). Every other component skips it entirely.
*
* Example: uppercase every component's output.
diff --git a/ext/html/html_arginfo.h b/ext/html/html_arginfo.h
index fd1a198f9df3..8b6d1ee5f490 100644
--- a/ext/html/html_arginfo.h
+++ b/ext/html/html_arginfo.h
@@ -1,5 +1,5 @@
/* This is a generated file, edit html.stub.php instead.
- * Stub hash: fe87170919c07ffdb0869adffcf45cd108d3bd96 */
+ * Stub hash: eaef41e6bb5861baded549bcdab72debb58206f6 */
ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_Html_raw, 0, 1, Html\\Htmlable, 0)
ZEND_ARG_TYPE_INFO(0, html, IS_STRING, 0)
diff --git a/ext/html/php_html.h b/ext/html/php_html.h
index a5db065e4865..9bb40aff0f1b 100644
--- a/ext/html/php_html.h
+++ b/ext/html/php_html.h
@@ -25,15 +25,15 @@ extern zend_module_entry html_module_entry;
#define PHP_HTML_VERSION PHP_VERSION
/* Request-scoped userland hooks that let a framework take over how a component
- * tag is turned into an object (RFC §4). `component_factories` replace the `new`
- * for a component (e.g. resolve through a DI container) — the *production* seam.
+ * tag is turned into an object. `component_factories` replace the `new`
+ * for a component (e.g. resolve through a DI container) - the *production* seam.
* `component_decorators` are the cross-cutting seam: they run on the produced
* Html\Htmlable of every component, so output transforms / wrapping / logging
* apply uniformly. Each is an ordered list of html_handler entries (callable +
* optional per-component scope) tried in registration order, like
* spl_autoload_register; a factory returns an object to take over the dispatch
* or null to defer, while each decorator wraps the previous one's result. A
- * scoped entry is skipped in C — before any userland call — for every other
+ * scoped entry is skipped in C - before any userland call - for every other
* component. Both are NULL until first registered and are cleared at request
* shutdown. */
ZEND_BEGIN_MODULE_GLOBALS(html)
diff --git a/ext/html/tests/children.phpt b/ext/html/tests/children.phpt
index 8189b5ec6e08..161f5963636a 100644
--- a/ext/html/tests/children.phpt
+++ b/ext/html/tests/children.phpt
@@ -1,5 +1,5 @@
--TEST--
-Html\Element children: scalar coercion, recursive array flattening, Traversables, and the nesting-depth bound (RFC §6)
+Html\Element children: scalar coercion, recursive array flattening, Traversables, and the nesting-depth bound
--EXTENSIONS--
html
--FILE--
diff --git a/ext/html/tests/component_hooks.phpt b/ext/html/tests/component_hooks.phpt
index f5cf61f2d4ba..ffa564db6309 100644
--- a/ext/html/tests/component_hooks.phpt
+++ b/ext/html/tests/component_hooks.phpt
@@ -1,5 +1,5 @@
--TEST--
-Html components: factory hooks (DI), decorators, and per-component hook scoping (RFC §4)
+Html components: factory hooks (DI), decorators, and per-component hook scoping
--EXTENSIONS--
html
--FILE--
@@ -147,7 +147,7 @@ var_dump(unregister_component_factory($cardFactory, 'Card'));
var_dump(unregister_component_factory($anyFactory));
echo , "\n";
-// (4) A decorator scoped to Badge wraps only Badge's output — Card renders
+// (4) A decorator scoped to Badge wraps only Badge's output - Card renders
// untouched.
$badgeDecorator = fn(Html\Htmlable $h, string $c) => Html\raw("<<$h>>");
register_component_decorator($badgeDecorator, Badge::class);
diff --git a/ext/html/tests/component_slots.phpt b/ext/html/tests/component_slots.phpt
index 15688251ced2..745b72d15178 100644
--- a/ext/html/tests/component_slots.phpt
+++ b/ext/html/tests/component_slots.phpt
@@ -1,5 +1,5 @@
--TEST--
-Html components: #[Html\Slot] body routing, slot validation errors, Slot attribute reflection (RFC §5)
+Html components: #[Html\Slot] body routing, slot validation errors, Slot attribute reflection
--EXTENSIONS--
html
--FILE--
diff --git a/ext/html/tests/components.phpt b/ext/html/tests/components.phpt
index 8cfd2e5e05c6..a25424d73ff8 100644
--- a/ext/html/tests/components.phpt
+++ b/ext/html/tests/components.phpt
@@ -1,5 +1,5 @@
--TEST--
-Html components: tag lowering, class/static-method dispatch, resolution errors, hyphenated attrs (RFC §4, §5)
+Html components: tag lowering, class/static-method dispatch, resolution errors, hyphenated attrs
--EXTENSIONS--
html
--FILE--
diff --git a/ext/html/tests/element.phpt b/ext/html/tests/element.phpt
index 0fe2d08e472c..a03eb50ac9cb 100644
--- a/ext/html/tests/element.phpt
+++ b/ext/html/tests/element.phpt
@@ -34,7 +34,7 @@ echo (new Element('div', ['title' => 'a "b" & '], []))->__toString(), "\n";
$evil = '';
echo (new Element('span', [], [$evil]))->__toString(), "\n";
-// --- attribute value coercion (RFC §5) ---
+// --- attribute value coercion ---
// null and false omit the attribute; true is a bare boolean attribute.
echo (new Element('input', [
diff --git a/ext/html/tests/htmlable.phpt b/ext/html/tests/htmlable.phpt
index 89a55404989d..88c32c305d23 100644
--- a/ext/html/tests/htmlable.phpt
+++ b/ext/html/tests/htmlable.phpt
@@ -6,7 +6,7 @@ html
)->__toString(), "\n";
diff --git a/ext/html/tests/syntax_doctype.phpt b/ext/html/tests/syntax_doctype.phpt
index 694cc85b50d8..3de7054b0e5c 100644
--- a/ext/html/tests/syntax_doctype.phpt
+++ b/ext/html/tests/syntax_doctype.phpt
@@ -1,5 +1,5 @@
--TEST--
-Markup syntax: emission rules and non-doctype emission rules and non-doctype /<{expr}> — classification, escaping, single evaluation, BC, error cases (RFC §4)
+Markup syntax: dynamic tags <$tag>/<{expr}> - classification, escaping, single evaluation, BC, error cases
--EXTENSIONS--
html
--FILE--
diff --git a/ext/html/tests/syntax_entities.phpt b/ext/html/tests/syntax_entities.phpt
index 5b856549287b..4cd7a97e5ce6 100644
--- a/ext/html/tests/syntax_entities.phpt
+++ b/ext/html/tests/syntax_entities.phpt
@@ -1,5 +1,5 @@
--TEST--
-Markup syntax: HTML character references decode in text/attributes; tokenizer keeps them raw (RFC §7)
+Markup syntax: HTML character references decode in text/attributes; tokenizer keeps them raw
--EXTENSIONS--
html
tokenizer
diff --git a/ext/html/tests/syntax_lowering.phpt b/ext/html/tests/syntax_lowering.phpt
index 5778571a5cd3..a338ead7989b 100644
--- a/ext/html/tests/syntax_lowering.phpt
+++ b/ext/html/tests/syntax_lowering.phpt
@@ -9,10 +9,9 @@ assert.exception=1