Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/happy-ways-collect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@astrojs/compiler": minor
---

Support HTML <selectedcontent> element

Based on the recent commit history, this change appears to be related to fixing issue #1093 regarding selectedcontent parsing in customizable selects. The <selectedcontent> element is part of the new Customizable Select Element API
in HTML, used within <selectlist> elements to display the currently selected option(s).
27 changes: 27 additions & 0 deletions internal/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -1418,6 +1418,14 @@ func inBodyIM(p *parser) bool {
return true
}
default:
// Special handling for selectedcontent as a void element
if p.tok.Data == "selectedcontent" {
p.reconstructActiveFormattingElements()
p.addElement()
p.oe.pop()
p.acknowledgeSelfClosingTag()
return true
}
p.reconstructActiveFormattingElements()
p.addElement()
if p.hasSelfClosingToken {
Expand All @@ -1436,6 +1444,12 @@ func inBodyIM(p *parser) bool {
return true
}

// Special handling for selectedcontent end tag - just ignore it
// since it's treated as a void element
if p.tok.Data == "selectedcontent" {
return true
}

Comment on lines +1447 to +1452
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why Ignore End Tags

The Problem Flow:
When HTML contains <selectedcontent></selectedcontent>:

  1. Tokenizer generates these tokens:
  • <selectedcontent> → StartTagToken
  • </selectedcontent> → EndTagToken
  1. Parser processing:
  • Treats as a void element (can't have children)
  • Adds it to DOM, then immediately pops it from the stack
  • At this point, selectedcontent is no longer on the stack
  1. When arrives:
  • There's no matching opening tag on the stack
  • If we don't ignore it: Parser gets confused and misplaces subsequent elements

What Actually Happened:

  <!-- Input -->
  <select><button><selectedcontent></selectedcontent></button><option>A</option></select>

  <!-- Wrong output (without ignoring the end tag) -->
  <select><button><selectedcontent><option>A</option></button></select>
  <!-- ↑ option becomes a child of selectedcontent (WRONG!) -->

switch p.tok.DataAtom {
case a.Body:
p.addLoc()
Expand Down Expand Up @@ -2319,6 +2333,19 @@ func inSelectIM(p *parser) bool {
p.resetInsertionMode()
case a.Template:
return inHeadIM(p)
default:
// Handle closing tags for elements that are allowed in customizable select
// (like button for the new HTML select element)
if p.tok.Data == "button" {
// Close the button if it's open
for i := len(p.oe) - 1; i >= 0; i-- {
if p.oe[i].Data == "button" {
p.oe = p.oe[:i]
break
}
}
return true
}
Comment on lines +2337 to +2348
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why support </button> tags in select context

  • In standard HTML, a <button> is not allowed inside a <select>.
  • The Customizable Select feature, however, allow interactive content (including <button>) inside <select>.
    Supporting </button> here is forward-compatible with that model.

before

EndTag </button>
Stack before: <html> <select> <button>
Stack after:  <html> <select> <button>   ← button NOT closed!

StartTag <option>
Stack after:  <html> <select> <button> <option>   ← option becomes child of button (WRONG!)

After

EndTag </button>
Stack before: <html> <select> <button>
Stack after:  <html> <select>              ← button correctly closed

StartTag <option>
Stack after:  <html> <select> <option>     ← option is direct child of select (CORRECT!)

}
case CommentToken:
p.addChild(&Node{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
[TestPrinter/selectedcontent_element_in_customizable_select - 1]
## Input

```
<select><button><selectedcontent></selectedcontent></button><option>Option 1</option><option>Option 2</option></select>
```

## Output

```js
import {
Fragment,
render as $$render,
createAstro as $$createAstro,
createComponent as $$createComponent,
renderComponent as $$renderComponent,
renderHead as $$renderHead,
maybeRenderHead as $$maybeRenderHead,
unescapeHTML as $$unescapeHTML,
renderSlot as $$renderSlot,
mergeSlots as $$mergeSlots,
addAttribute as $$addAttribute,
spreadAttributes as $$spreadAttributes,
defineStyleVars as $$defineStyleVars,
defineScriptVars as $$defineScriptVars,
renderTransition as $$renderTransition,
createTransitionScope as $$createTransitionScope,
renderScript as $$renderScript,
createMetadata as $$createMetadata
} from "http://localhost:3000/";

export const $$metadata = $$createMetadata(import.meta.url, { modules: [], hydratedComponents: [], clientOnlyComponents: [], hydrationDirectives: new Set([]), hoisted: [] });

const $$Component = $$createComponent(($$result, $$props, $$slots) => {

return $$render`${$$maybeRenderHead($$result)}<select><button><selectedcontent></button><option>Option 1</option><option>Option 2</option></select>`;
}, undefined, undefined);
export default $$Component;
```
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
[TestPrinter/selectedcontent_self-closing_element - 1]
## Input

```
<select><button><selectedcontent /></button><option>Option 1</option><option>Option 2</option></select>
```

## Output

```js
import {
Fragment,
render as $$render,
createAstro as $$createAstro,
createComponent as $$createComponent,
renderComponent as $$renderComponent,
renderHead as $$renderHead,
maybeRenderHead as $$maybeRenderHead,
unescapeHTML as $$unescapeHTML,
renderSlot as $$renderSlot,
mergeSlots as $$mergeSlots,
addAttribute as $$addAttribute,
spreadAttributes as $$spreadAttributes,
defineStyleVars as $$defineStyleVars,
defineScriptVars as $$defineScriptVars,
renderTransition as $$renderTransition,
createTransitionScope as $$createTransitionScope,
renderScript as $$renderScript,
createMetadata as $$createMetadata
} from "http://localhost:3000/";

export const $$metadata = $$createMetadata(import.meta.url, { modules: [], hydratedComponents: [], clientOnlyComponents: [], hydrationDirectives: new Set([]), hoisted: [] });

const $$Component = $$createComponent(($$result, $$props, $$slots) => {

return $$render`${$$maybeRenderHead($$result)}<select><button><selectedcontent></button><option>Option 1</option><option>Option 2</option></select>`;
}, undefined, undefined);
export default $$Component;
```
---
31 changes: 16 additions & 15 deletions internal/printer/print-to-js.go
Original file line number Diff line number Diff line change
Expand Up @@ -820,19 +820,20 @@ func render1(p *printer, n *Node, opts RenderOptions) {
// are those that can't have any contents.
// nolint
var voidElements = map[string]bool{
"area": true,
"base": true,
"br": true,
"col": true,
"embed": true,
"hr": true,
"img": true,
"input": true,
"keygen": true, // "keygen" has been removed from the spec, but are kept here for backwards compatibility.
"link": true,
"meta": true,
"param": true,
"source": true,
"track": true,
"wbr": true,
"area": true,
"base": true,
"br": true,
"col": true,
"embed": true,
"hr": true,
"img": true,
"input": true,
"keygen": true, // "keygen" has been removed from the spec, but are kept here for backwards compatibility.
"link": true,
"meta": true,
"param": true,
"selectedcontent": true,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just add selectedcontent, then apply formatting

"source": true,
"track": true,
"wbr": true,
}
8 changes: 8 additions & 0 deletions internal/printer/printer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1535,6 +1535,14 @@ const items = ["Dog", "Cat", "Platipus"];
name: "select in form",
source: `<form><select>{options.map((option) => (<option value={option.id}>{option.title}</option>))}</select><div><label>Title 3</label><input type="text" /></div><button type="submit">Submit</button></form>`,
},
{
name: "selectedcontent element in customizable select",
source: `<select><button><selectedcontent></selectedcontent></button><option>Option 1</option><option>Option 2</option></select>`,
},
{
name: "selectedcontent self-closing element",
source: `<select><button><selectedcontent /></button><option>Option 1</option><option>Option 2</option></select>`,
},
{
name: "Expression in form followed by other sibling forms",
source: "<form><p>No expression here. So the next form will render.</p></form><form><h3>{data.formLabelA}</h3></form><form><h3>{data.formLabelB}</h3></form><form><p>No expression here, but the last form before me had an expression, so my form didn't render.</p></form><form><h3>{data.formLabelC}</h3></form><div><p>Here is some in-between content</p></div><form><h3>{data.formLabelD}</h3></form>",
Expand Down
8 changes: 7 additions & 1 deletion internal/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -1211,12 +1211,18 @@ func (z *Tokenizer) readStartTag() TokenType {
}

// HTML void tags list: https://www.w3.org/TR/2011/WD-html-markup-20110113/syntax.html#syntax-elements
// Also look for a self-closing token thats not in the list (e.g. "<svg><path/></svg>")
// Also look for a self-closing token that's not in the list (e.g. "<svg><path/></svg>")
if z.startTagIn("area", "base", "br", "col", "command", "embed", "hr", "img", "input", "keygen", "link", "meta", "param", "source", "track", "wbr") || z.err == nil && z.buf[z.raw.End-2] == '/' {
// Reset tokenizer state for self-closing elements
z.rawTag = ""
return SelfClosingTagToken
}
// Special handling for selectedcontent - it's void but can have a closing tag in HTML
if z.startTagIn("selectedcontent") && z.err == nil && z.buf[z.raw.End-2] == '/' {
// Only treat as self-closing if it actually has />
z.rawTag = ""
return SelfClosingTagToken
}

// Handle TypeScript Generics
if len(z.expressionElementStack) > 0 && len(z.expressionElementStack[len(z.expressionElementStack)-1]) == 0 {
Expand Down
10 changes: 10 additions & 0 deletions internal/token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,16 @@ func TestBasic(t *testing.T) {
`<select>{[1, 2, 3].map(num => <option>{num}</option>)}</select><div>Hello</div>`,
[]TokenType{StartTagToken, StartExpressionToken, TextToken, StartTagToken, StartExpressionToken, TextToken, EndExpressionToken, EndTagToken, TextToken, EndExpressionToken, EndTagToken, StartTagToken, TextToken, EndTagToken},
},
{
"selectedcontent element",
`<select><button><selectedcontent></selectedcontent></button><option>A</option></select>`,
[]TokenType{StartTagToken, StartTagToken, StartTagToken, EndTagToken, EndTagToken, StartTagToken, TextToken, EndTagToken, EndTagToken},
},
{
"selectedcontent self-closing",
`<select><button><selectedcontent /></button><option>A</option></select>`,
[]TokenType{StartTagToken, StartTagToken, SelfClosingTagToken, EndTagToken, StartTagToken, TextToken, EndTagToken, EndTagToken},
},
{
"single open brace",
"<main id={`{`}></main>",
Expand Down