Skip to content

Commit 3e25858

Browse files
authored
feat(tsx): Extract script and styles from TSX for language-server consumption (#1019)
* feat(tsx): Extract script and styles from TSX for language-server consumption * feat: add options for styles * fix: reverse order of operation * test: fix * test: add tests * fix: count printed bytes so that we can skip over them later * chore: fix format * chore: uugh * chore: biome what is GOING on * fix(tsx): rework how escaping work (#1023) * fix(tsx): rework how escaping work * nit: remove duplicate code * nit: formatting * chore: changeset * fix: also do it for escaped text
1 parent ed25fe0 commit 3e25858

File tree

8 files changed

+486
-67
lines changed

8 files changed

+486
-67
lines changed

.changeset/olive-melons-sleep.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@astrojs/compiler": minor
3+
---
4+
5+
Adds two new options to `convertToTSX`: `includeScripts` and `includeStyles`. These options allow you to optionally remove scripts and styles from the output TSX file.
6+
7+
Additionally this PR makes it so scripts and styles metadata are now included in the `metaRanges` property of the result of `convertToTSX`. This is notably useful in order to extract scripts and styles from the output TSX file into separate files for language servers.

cmd/astro-wasm/astro-wasm.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ func jsString(j js.Value) string {
3737
return j.String()
3838
}
3939

40+
func jsBoolOptional(j js.Value, defaultValue bool) bool {
41+
if j.Equal(js.Undefined()) || j.Equal(js.Null()) {
42+
return defaultValue
43+
}
44+
return j.Bool()
45+
}
46+
4047
func jsBool(j js.Value) bool {
4148
if j.Equal(js.Undefined()) || j.Equal(js.Null()) {
4249
return false
@@ -148,6 +155,16 @@ func makeTransformOptions(options js.Value) transform.TransformOptions {
148155
}
149156
}
150157

158+
func makeTSXOptions(options js.Value) printer.TSXOptions {
159+
includeScripts := jsBoolOptional(options.Get("includeScripts"), true)
160+
includeStyles := jsBoolOptional(options.Get("includeStyles"), true)
161+
162+
return printer.TSXOptions{
163+
IncludeScripts: includeScripts,
164+
IncludeStyles: includeStyles,
165+
}
166+
}
167+
151168
type RawSourceMap struct {
152169
File string `js:"file"`
153170
Mappings string `js:"mappings"`
@@ -260,7 +277,10 @@ func ConvertToTSX() any {
260277
if err != nil {
261278
h.AppendError(err)
262279
}
263-
result := printer.PrintToTSX(source, doc, transformOptions, h)
280+
281+
tsxOptions := makeTSXOptions(js.Value(args[1]))
282+
283+
result := printer.PrintToTSX(source, doc, tsxOptions, transformOptions, h)
264284

265285
// AFTER printing, exec transformations to pickup any errors/warnings
266286
transform.Transform(doc, transformOptions, h)

internal/printer/print-to-tsx.go

Lines changed: 209 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,20 @@ func getTSXPrefix() string {
2020
return "/* @jsxImportSource astro */\n\n"
2121
}
2222

23-
func PrintToTSX(sourcetext string, n *Node, opts transform.TransformOptions, h *handler.Handler) PrintResult {
23+
type TSXOptions struct {
24+
IncludeScripts bool
25+
IncludeStyles bool
26+
}
27+
28+
func PrintToTSX(sourcetext string, n *Node, opts TSXOptions, transformOpts transform.TransformOptions, h *handler.Handler) PrintResult {
2429
p := &printer{
2530
sourcetext: sourcetext,
26-
opts: opts,
31+
opts: transformOpts,
2732
builder: sourcemap.MakeChunkBuilder(nil, sourcemap.GenerateLineOffsetTables(sourcetext, len(strings.Split(sourcetext, "\n")))),
2833
}
2934
p.print(getTSXPrefix())
30-
renderTsx(p, n)
35+
renderTsx(p, n, &opts)
36+
3137
return PrintResult{
3238
Output: p.output,
3339
SourceMapChunk: p.builder.GenerateChunk(p.output),
@@ -36,14 +42,147 @@ func PrintToTSX(sourcetext string, n *Node, opts transform.TransformOptions, h *
3642
}
3743

3844
type TSXRanges struct {
39-
Frontmatter loc.TSXRange `js:"frontmatter"`
40-
Body loc.TSXRange `js:"body"`
45+
Frontmatter loc.TSXRange `js:"frontmatter"`
46+
Body loc.TSXRange `js:"body"`
47+
Scripts []TSXExtractedTag `js:"scripts"`
48+
Styles []TSXExtractedTag `js:"styles"`
49+
}
50+
51+
var htmlEvents = map[string]bool{
52+
"onabort": true,
53+
"onafterprint": true,
54+
"onauxclick": true,
55+
"onbeforematch": true,
56+
"onbeforeprint": true,
57+
"onbeforeunload": true,
58+
"onblur": true,
59+
"oncancel": true,
60+
"oncanplay": true,
61+
"oncanplaythrough": true,
62+
"onchange": true,
63+
"onclick": true,
64+
"onclose": true,
65+
"oncontextlost": true,
66+
"oncontextmenu": true,
67+
"oncontextrestored": true,
68+
"oncopy": true,
69+
"oncuechange": true,
70+
"oncut": true,
71+
"ondblclick": true,
72+
"ondrag": true,
73+
"ondragend": true,
74+
"ondragenter": true,
75+
"ondragleave": true,
76+
"ondragover": true,
77+
"ondragstart": true,
78+
"ondrop": true,
79+
"ondurationchange": true,
80+
"onemptied": true,
81+
"onended": true,
82+
"onerror": true,
83+
"onfocus": true,
84+
"onformdata": true,
85+
"onhashchange": true,
86+
"oninput": true,
87+
"oninvalid": true,
88+
"onkeydown": true,
89+
"onkeypress": true,
90+
"onkeyup": true,
91+
"onlanguagechange": true,
92+
"onload": true,
93+
"onloadeddata": true,
94+
"onloadedmetadata": true,
95+
"onloadstart": true,
96+
"onmessage": true,
97+
"onmessageerror": true,
98+
"onmousedown": true,
99+
"onmouseenter": true,
100+
"onmouseleave": true,
101+
"onmousemove": true,
102+
"onmouseout": true,
103+
"onmouseover": true,
104+
"onmouseup": true,
105+
"onoffline": true,
106+
"ononline": true,
107+
"onpagehide": true,
108+
"onpageshow": true,
109+
"onpaste": true,
110+
"onpause": true,
111+
"onplay": true,
112+
"onplaying": true,
113+
"onpopstate": true,
114+
"onprogress": true,
115+
"onratechange": true,
116+
"onrejectionhandled": true,
117+
"onreset": true,
118+
"onresize": true,
119+
"onscroll": true,
120+
"onscrollend": true,
121+
"onsecuritypolicyviolation": true,
122+
"onseeked": true,
123+
"onseeking": true,
124+
"onselect": true,
125+
"onslotchange": true,
126+
"onstalled": true,
127+
"onstorage": true,
128+
"onsubmit": true,
129+
"onsuspend": true,
130+
"ontimeupdate": true,
131+
"ontoggle": true,
132+
"onunhandledrejection": true,
133+
"onunload": true,
134+
"onvolumechange": true,
135+
"onwaiting": true,
136+
"onwheel": true,
137+
}
138+
139+
func getScriptTypeForNode(n Node) string {
140+
if n.Attr == nil || len(n.Attr) == 0 {
141+
return "processed-module"
142+
}
143+
144+
// If the script tag has `type="module"`, it's not processed, but it's still a module
145+
for _, attr := range n.Attr {
146+
if attr.Key == "type" {
147+
if strings.Contains(attr.Val, "module") {
148+
return "module"
149+
}
150+
151+
if ScriptJSONMimeTypes[strings.ToLower(attr.Val)] {
152+
return "json"
153+
}
154+
}
155+
156+
}
157+
158+
// Otherwise, it's an inline script
159+
return "inline"
160+
}
161+
162+
type TSXExtractedTag struct {
163+
Loc loc.TSXRange `js:"position"`
164+
Type string `js:"type"`
165+
Content string `js:"content"`
41166
}
42167

43168
func isScript(p *astro.Node) bool {
44169
return p.DataAtom == atom.Script
45170
}
46171

172+
func isStyle(p *astro.Node) bool {
173+
return p.DataAtom == atom.Style
174+
}
175+
176+
// Has is:raw attribute
177+
func isRawText(p *astro.Node) bool {
178+
for _, a := range p.Attr {
179+
if a.Key == "is:raw" {
180+
return true
181+
}
182+
}
183+
return false
184+
}
185+
47186
var ScriptMimeTypes map[string]bool = map[string]bool{
48187
"module": true,
49188
"text/typescript": true,
@@ -52,6 +191,13 @@ var ScriptMimeTypes map[string]bool = map[string]bool{
52191
"application/node": true,
53192
}
54193

194+
var ScriptJSONMimeTypes map[string]bool = map[string]bool{
195+
"application/json": true,
196+
"application/ld+json": true,
197+
"importmap": true,
198+
"speculationrules": true,
199+
}
200+
55201
// This is not perfect (as in, you wouldn't use this to make a spec compliant parser), but it's good enough
56202
// for the real world. Thankfully, JSX is also a bit more lax than JavaScript, so we can spare some work.
57203
func isValidTSXAttribute(a Attribute) bool {
@@ -95,20 +241,35 @@ type TextType uint32
95241

96242
const (
97243
RawText TextType = iota
244+
Text
98245
ScriptText
246+
JsonScriptText
247+
StyleText
99248
)
100249

101250
func getTextType(n *astro.Node) TextType {
102251
if script := n.Closest(isScript); script != nil {
103252
attr := astro.GetAttribute(script, "type")
104-
if attr == nil || (attr != nil && ScriptMimeTypes[strings.ToLower(attr.Val)]) {
253+
if attr == nil || ScriptMimeTypes[strings.ToLower(attr.Val)] {
105254
return ScriptText
106255
}
256+
257+
if attr != nil && ScriptJSONMimeTypes[strings.ToLower(attr.Val)] {
258+
return JsonScriptText
259+
}
260+
}
261+
if style := n.Closest(isStyle); style != nil {
262+
return StyleText
263+
}
264+
265+
if n.Closest(isRawText) != nil {
266+
return RawText
107267
}
108-
return RawText
268+
269+
return Text
109270
}
110271

111-
func renderTsx(p *printer, n *Node) {
272+
func renderTsx(p *printer, n *Node, o *TSXOptions) {
112273
// Root of the document, print all children
113274
if n.Type == DocumentNode {
114275
source := []byte(p.sourcetext)
@@ -147,7 +308,7 @@ func renderTsx(p *printer, n *Node) {
147308

148309
hasChildren = true
149310
}
150-
renderTsx(p, c)
311+
renderTsx(p, c, o)
151312
}
152313
p.addSourceMapping(loc.Loc{Start: len(p.sourcetext)})
153314
p.print("\n")
@@ -206,7 +367,7 @@ declare const Astro: Readonly<import('astro').AstroGlobal<%s, typeof %s`, propsI
206367
}
207368
p.printTextWithSourcemap(c.Data, c.Loc[0])
208369
} else {
209-
renderTsx(p, c)
370+
renderTsx(p, c, o)
210371
}
211372
}
212373
if n.FirstChild != nil {
@@ -224,22 +385,27 @@ declare const Astro: Readonly<import('astro').AstroGlobal<%s, typeof %s`, propsI
224385

225386
switch n.Type {
226387
case TextNode:
227-
if getTextType(n) == ScriptText {
228-
p.addNilSourceMapping()
229-
p.print("\n{() => {")
230-
p.printTextWithSourcemap(n.Data, n.Loc[0])
388+
textType := getTextType(n)
389+
if textType == ScriptText {
231390
p.addNilSourceMapping()
232-
p.print("}}\n")
391+
if o.IncludeScripts {
392+
p.print("\n{() => {")
393+
p.printTextWithSourcemap(n.Data, n.Loc[0])
394+
p.addNilSourceMapping()
395+
p.print("}}\n")
396+
}
233397
p.addSourceMapping(loc.Loc{Start: n.Loc[0].Start + len(n.Data)})
234-
return
235-
} else if strings.ContainsAny(n.Data, "{}<>'\"") && n.Data[0] != '<' {
236-
p.addNilSourceMapping()
237-
p.print("{`")
238-
p.printTextWithSourcemap(escapeText(n.Data), n.Loc[0])
398+
} else if textType == StyleText || textType == JsonScriptText || textType == RawText {
239399
p.addNilSourceMapping()
240-
p.print("`}")
400+
if (textType == StyleText && o.IncludeStyles) || textType == JsonScriptText || textType == RawText {
401+
p.print("{`")
402+
p.printTextWithSourcemap(escapeText(n.Data), n.Loc[0])
403+
p.addNilSourceMapping()
404+
p.print("`}")
405+
}
406+
p.addSourceMapping(loc.Loc{Start: n.Loc[0].Start + len(n.Data)})
241407
} else {
242-
p.printTextWithSourcemap(n.Data, n.Loc[0])
408+
p.printEscapedJSXTextWithSourcemap(n.Data, n.Loc[0])
243409
}
244410
return
245411
case ElementNode:
@@ -284,7 +450,7 @@ declare const Astro: Readonly<import('astro').AstroGlobal<%s, typeof %s`, propsI
284450
p.addNilSourceMapping()
285451
p.print(`<Fragment>`)
286452
}
287-
renderTsx(p, c)
453+
renderTsx(p, c, o)
288454
if c.NextSibling == nil || c.NextSibling.Type == TextNode {
289455
p.addNilSourceMapping()
290456
p.print(`</Fragment>`)
@@ -310,7 +476,7 @@ declare const Astro: Readonly<import('astro').AstroGlobal<%s, typeof %s`, propsI
310476
if isImplicit {
311477
// Render any child nodes
312478
for c := n.FirstChild; c != nil; c = c.NextSibling {
313-
renderTsx(p, c)
479+
renderTsx(p, c, o)
314480
}
315481
return
316482
}
@@ -360,6 +526,12 @@ declare const Astro: Readonly<import('astro').AstroGlobal<%s, typeof %s`, propsI
360526
p.print(`"`)
361527
endLoc = a.ValLoc.Start
362528
}
529+
if _, ok := htmlEvents[a.Key]; ok {
530+
p.addTSXScript(a.ValLoc.Start-p.bytesToSkip, endLoc-p.bytesToSkip, a.Val, "event-attribute")
531+
}
532+
if a.Key == "style" {
533+
p.addTSXStyle(a.ValLoc.Start-p.bytesToSkip, endLoc-p.bytesToSkip, a.Val, "style-attribute")
534+
}
363535
case astro.EmptyAttribute:
364536
p.print(a.Key)
365537
endLoc = a.KeyLoc.Start + len(a.Key)
@@ -521,15 +693,27 @@ declare const Astro: Readonly<import('astro').AstroGlobal<%s, typeof %s`, propsI
521693
}
522694
p.print(">")
523695

696+
startTagEnd := endLoc - p.bytesToSkip
697+
524698
// Render any child nodes
525699
for c := n.FirstChild; c != nil; c = c.NextSibling {
526-
renderTsx(p, c)
700+
renderTsx(p, c, o)
527701
if len(c.Loc) > 1 {
528702
endLoc = c.Loc[1].Start + len(c.Data) + 1
529703
} else if len(c.Loc) == 1 {
530704
endLoc = c.Loc[0].Start + len(c.Data)
531705
}
532706
}
707+
708+
if n.FirstChild != nil && (n.DataAtom == atom.Script || n.DataAtom == atom.Style) {
709+
if n.DataAtom == atom.Script {
710+
p.addTSXScript(startTagEnd, endLoc-p.bytesToSkip, n.FirstChild.Data, getScriptTypeForNode(*n))
711+
}
712+
if n.DataAtom == atom.Style {
713+
p.addTSXStyle(startTagEnd, endLoc-p.bytesToSkip, n.FirstChild.Data, "tag")
714+
}
715+
}
716+
533717
// Special case because of trailing expression close in scripts
534718
if n.DataAtom == atom.Script {
535719
p.printf("</%s>", n.Data)

0 commit comments

Comments
 (0)