diff --git a/.changeset/yellow-numbers-leave.md b/.changeset/yellow-numbers-leave.md new file mode 100644 index 000000000..3079a7bc3 --- /dev/null +++ b/.changeset/yellow-numbers-leave.md @@ -0,0 +1,5 @@ +--- +'@astrojs/compiler': minor +--- + +Adds support for `Astro.self` (as accepted in the [Recursive Components RFC](https://github.com/withastro/rfcs/blob/main/active-rfcs/0000-recursive-components.md)). diff --git a/cmd/astro-wasm/astro-wasm.go b/cmd/astro-wasm/astro-wasm.go index 94d5a64eb..a77cb6dbd 100644 --- a/cmd/astro-wasm/astro-wasm.go +++ b/cmd/astro-wasm/astro-wasm.go @@ -14,6 +14,7 @@ import ( "github.com/norunners/vert" astro "github.com/withastro/compiler/internal" "github.com/withastro/compiler/internal/printer" + t "github.com/withastro/compiler/internal/t" "github.com/withastro/compiler/internal/transform" wasm_utils "github.com/withastro/compiler/internal_wasm/utils" "golang.org/x/net/html/atom" @@ -23,6 +24,7 @@ var done chan bool func main() { js.Global().Set("__astro_transform", Transform()) + js.Global().Set("__astro_parse", Parse()) // This ensures that the WASM doesn't exit early <-make(chan bool) } @@ -41,6 +43,19 @@ func jsBool(j js.Value) bool { return j.Bool() } +func makeParseOptions(options js.Value) t.ParseOptions { + position := true + + pos := options.Get("position") + if !pos.IsNull() && !pos.IsUndefined() { + position = pos.Bool() + } + + return t.ParseOptions{ + Position: position, + } +} + func makeTransformOptions(options js.Value, hash string) transform.TransformOptions { filename := jsString(options.Get("sourcefile")) if filename == "" { @@ -107,6 +122,10 @@ type HoistedScript struct { Type string `js:"type"` } +type ParseResult struct { + AST string `js:"ast"` +} + type TransformResult struct { Code string `js:"code"` Map string `js:"map"` @@ -133,6 +152,36 @@ func preprocessStyle(i int, style *astro.Node, transformOptions transform.Transf style.FirstChild.Data = str } +func Parse() interface{} { + return js.FuncOf(func(this js.Value, args []js.Value) interface{} { + source := jsString(args[0]) + parseOptions := makeParseOptions(js.Value(args[1])) + + var doc *astro.Node + nodes, err := astro.ParseFragment(strings.NewReader(source), &astro.Node{ + Type: astro.ElementNode, + Data: atom.Template.String(), + DataAtom: atom.Template, + }) + if err != nil { + fmt.Println(err) + } + doc = &astro.Node{ + Type: astro.DocumentNode, + } + for i := 0; i < len(nodes); i++ { + n := nodes[i] + doc.AppendChild(n) + } + + result := printer.PrintToJSON(source, doc, parseOptions) + + return vert.ValueOf(ParseResult{ + AST: string(result.Output), + }) + }) +} + func Transform() interface{} { return js.FuncOf(func(this js.Value, args []js.Value) interface{} { source := jsString(args[0]) diff --git a/go.mod b/go.mod index 97342ec73..00a259e1b 100644 --- a/go.mod +++ b/go.mod @@ -11,4 +11,6 @@ require ( golang.org/x/net v0.0.0-20210716203947-853a461950ff ) +require github.com/iancoleman/strcase v0.2.0 // indirect + replace github.com/norunners/vert => github.com/natemoo-re/vert v0.0.0-natemoo-re.7 diff --git a/go.sum b/go.sum index ac607f966..4530539b0 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/iancoleman/strcase v0.2.0 h1:05I4QRnGpI0m37iZQRuskXh+w77mr6Z41lwQzuHLwW0= +github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= github.com/natemoo-re/vert v0.0.0-natemoo-re.7 h1:nhfKslS16o2Uruqt8Bwv8ZFYUuf+PW9iC2M5HI/Bs6U= diff --git a/internal/const.go b/internal/const.go index 3aad85779..370a3185d 100644 --- a/internal/const.go +++ b/internal/const.go @@ -4,6 +4,8 @@ package astro +import a "golang.org/x/net/html/atom" + // Section 12.2.4.2 of the HTML5 specification says "The following elements // have varying levels of special parsing rules". // https://html.spec.whatwg.org/multipage/syntax.html#the-stack-of-open-elements @@ -109,3 +111,26 @@ func isSpecialElement(element *Node) bool { } return false } + +var knownDirectiveMap = map[string]bool{ + "client:load": true, + "client:idle": true, + "client:visible": true, + "client:only": true, + "class:list": true, + "set:text": true, + "set:html": true, +} + +func IsKnownDirective(element *Node, attr *Attribute) bool { + if knownDirectiveMap[attr.Key] { + return true + } + if element.DataAtom == a.Script { + return attr.Key == "hoist" + } + if element.DataAtom == a.Style { + return attr.Key == "global" + } + return false +} diff --git a/internal/node.go b/internal/node.go index 0c55b5cab..3e920f117 100644 --- a/internal/node.go +++ b/internal/node.go @@ -30,6 +30,29 @@ const ( ExpressionNode ) +func (t NodeType) String() string { + switch t { + case ErrorNode: + return "error" + case TextNode: + return "text" + case DocumentNode: + return "root" + case ElementNode: + return "element" + case CommentNode: + return "comment" + case DoctypeNode: + return "doctype" + case FrontmatterNode: + return "frontmatter" + case ExpressionNode: + return "expression" + default: + return "" + } +} + // Used as an Attribute Key to mark implicit nodes const ImplicitNodeMarker = "\x00implicit" diff --git a/internal/parser.go b/internal/parser.go index 39a868d49..88b6c5827 100644 --- a/internal/parser.go +++ b/internal/parser.go @@ -365,6 +365,7 @@ func (p *parser) addFrontmatter(empty bool) { } if empty { p.frontmatterState = FrontmatterClosed + p.fm.Attr = append(p.fm.Attr, Attribute{Key: ImplicitNodeMarker, Type: EmptyAttribute}) } else { p.frontmatterState = FrontmatterOpen p.oe = append(p.oe, p.fm) diff --git a/internal/printer/print-to-js.go b/internal/printer/print-to-js.go index 751195304..a2352d8aa 100644 --- a/internal/printer/print-to-js.go +++ b/internal/printer/print-to-js.go @@ -106,8 +106,7 @@ func render1(p *printer, n *Node, opts RenderOptions) { } p.printReturnClose() - // TODO: use proper component name - p.printFuncSuffix("$$Component") + p.printFuncSuffix(opts.opts) return } @@ -149,8 +148,7 @@ func render1(p *printer, n *Node, opts RenderOptions) { // 3. The metadata object p.printComponentMetadata(n.Parent, opts.opts, []byte(c.Data)) - // TODO: use the proper component name - p.printFuncPrelude("$$Component") + p.printFuncPrelude(opts.opts) } else { importStatements := c.Data[0:renderBodyStart] content := c.Data[renderBodyStart:] @@ -176,8 +174,7 @@ func render1(p *printer, n *Node, opts RenderOptions) { } } - // TODO: use the proper component name - p.printFuncPrelude("$$Component") + p.printFuncPrelude(opts.opts) if len(c.Loc) > 0 { p.addSourceMapping(loc.Loc{Start: c.Loc[0].Start + renderBodyStart}) } @@ -225,8 +222,7 @@ func render1(p *printer, n *Node, opts RenderOptions) { p.printTopLevelAstro(opts.opts) // Render func prelude. Will only run for the first non-frontmatter node - // TODO: use the proper component name - p.printFuncPrelude("$$Component") + p.printFuncPrelude(opts.opts) // This just ensures a newline p.println("") diff --git a/internal/printer/print-to-json.go b/internal/printer/print-to-json.go new file mode 100644 index 000000000..6780aef57 --- /dev/null +++ b/internal/printer/print-to-json.go @@ -0,0 +1,241 @@ +package printer + +import ( + "fmt" + "regexp" + "strings" + + . "github.com/withastro/compiler/internal" + "github.com/withastro/compiler/internal/loc" + "github.com/withastro/compiler/internal/sourcemap" + "github.com/withastro/compiler/internal/t" + "github.com/withastro/compiler/internal/transform" +) + +type ASTPosition struct { + Start ASTPoint `json:"start,omitempty"` + End ASTPoint `json:"end,omitempty"` +} + +type ASTPoint struct { + Line int `json:"line,omitempty"` + Column int `json:"column,omitempty"` + Offset int `json:"offset,omitempty"` +} + +type ASTNode struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + Value string `json:"value,omitempty"` + Attributes []ASTNode `json:"attributes,omitempty"` + Directives []ASTNode `json:"directives,omitempty"` + Children []ASTNode `json:"children,omitempty"` + Position ASTPosition `json:"position,omitempty"` + + // Attributes only + Kind string `json:"kind,omitempty"` +} + +func escapeForJSON(value string) string { + newlines := regexp.MustCompile(`\n`) + value = newlines.ReplaceAllString(value, `\n`) + doublequotes := regexp.MustCompile(`"`) + value = doublequotes.ReplaceAllString(value, `\"`) + amp := regexp.MustCompile(`&`) + value = amp.ReplaceAllString(value, `\&`) + r := regexp.MustCompile(`\r`) + value = r.ReplaceAllString(value, `\r`) + t := regexp.MustCompile(`\t`) + value = t.ReplaceAllString(value, `\t`) + f := regexp.MustCompile(`\f`) + value = f.ReplaceAllString(value, `\f`) + return value +} + +func (n ASTNode) String() string { + str := fmt.Sprintf(`{"type":"%s"`, n.Type) + if n.Kind != "" { + str += fmt.Sprintf(`,"kind":"%s"`, n.Kind) + } + if n.Name != "" { + str += fmt.Sprintf(`,"name":"%s"`, escapeForJSON(n.Name)) + } + if n.Value != "" || n.Type == "attribute" { + str += fmt.Sprintf(`,"value":"%s"`, escapeForJSON(n.Value)) + } + if len(n.Attributes) > 0 { + str += `,"attributes":[` + for i, attr := range n.Attributes { + str += attr.String() + if i < len(n.Attributes)-1 { + str += "," + } + } + str += `]` + } + if len(n.Directives) > 0 { + str += `,"directives":[` + for i, attr := range n.Directives { + str += attr.String() + if i < len(n.Directives)-1 { + str += "," + } + } + str += `]` + } + if len(n.Children) > 0 { + str += `,"children":[` + for i, node := range n.Children { + str += node.String() + if i < len(n.Children)-1 { + str += "," + } + } + str += `]` + } + if n.Position.Start.Line != 0 { + str += `,"position":{` + str += fmt.Sprintf(`"start":{"line":%d,"column":%d,"offset":%d}`, n.Position.Start.Line, n.Position.Start.Column, n.Position.Start.Offset) + if n.Position.End.Line != 0 { + str += fmt.Sprintf(`,"end":{"line":%d,"column":%d,"offset":%d}`, n.Position.End.Line, n.Position.End.Column, n.Position.End.Offset) + } + str += "}" + } + str += "}" + return str +} + +func PrintToJSON(sourcetext string, n *Node, opts t.ParseOptions) PrintResult { + p := &printer{ + builder: sourcemap.MakeChunkBuilder(nil, sourcemap.GenerateLineOffsetTables(sourcetext, len(strings.Split(sourcetext, "\n")))), + } + root := ASTNode{} + renderNode(p, &root, n, opts) + doc := root.Children[0] + return PrintResult{ + Output: []byte(doc.String()), + } +} + +func locToPoint(p *printer, loc loc.Loc) ASTPoint { + offset := loc.Start + info := p.builder.GetLineAndColumnForLocation(loc) + line := info[0] + column := info[1] + + return ASTPoint{ + Line: line, + Column: column, + Offset: offset, + } +} + +func positionAt(p *printer, n *Node, opts t.ParseOptions) ASTPosition { + if !opts.Position { + return ASTPosition{} + } + + if len(n.Loc) == 1 { + s := n.Loc[0] + start := locToPoint(p, s) + + return ASTPosition{ + Start: start, + } + } + + if len(n.Loc) == 2 { + s := n.Loc[0] + e := n.Loc[1] + start := locToPoint(p, s) + end := locToPoint(p, e) + + return ASTPosition{ + Start: start, + End: end, + } + } + return ASTPosition{} +} + +func attrPositionAt(p *printer, n *Attribute, opts t.ParseOptions) ASTPosition { + if !opts.Position { + return ASTPosition{} + } + + k := n.KeyLoc + start := locToPoint(p, k) + + return ASTPosition{ + Start: start, + } +} + +func renderNode(p *printer, parent *ASTNode, n *Node, opts t.ParseOptions) { + isImplicit := false + for _, a := range n.Attr { + if transform.IsImplictNodeMarker(a) { + isImplicit = true + break + } + } + hasChildren := n.FirstChild != nil + if isImplicit { + if hasChildren { + for c := n.FirstChild; c != nil; c = c.NextSibling { + renderNode(p, parent, c, opts) + } + } + return + } + var node ASTNode + + node.Position = positionAt(p, n, opts) + + if n.Type == ElementNode { + if n.Expression { + node.Type = "expression" + } else { + node.Name = n.Data + if n.Component { + node.Type = "component" + } else if n.CustomElement { + node.Type = "custom-element" + } else { + node.Type = "element" + } + + for _, attr := range n.Attr { + attrNode := ASTNode{ + Type: "attribute", + Kind: attr.Type.String(), + Position: attrPositionAt(p, &attr, opts), + Name: attr.Key, + Value: attr.Val, + } + if IsKnownDirective(n, &attr) { + attrNode.Type = "directive" + node.Directives = append(node.Directives, attrNode) + } else { + node.Attributes = append(node.Attributes, attrNode) + } + } + } + } else { + node.Type = n.Type.String() + if n.Type == TextNode || n.Type == CommentNode || n.Type == DoctypeNode { + node.Value = n.Data + } + } + if n.Type == FrontmatterNode && hasChildren { + node.Value = n.FirstChild.Data + } else { + if !isImplicit && hasChildren { + for c := n.FirstChild; c != nil; c = c.NextSibling { + renderNode(p, &node, c, opts) + } + } + } + + parent.Children = append(parent.Children, node) +} diff --git a/internal/printer/printer.go b/internal/printer/printer.go index b408077dc..4621f5aca 100644 --- a/internal/printer/printer.go +++ b/internal/printer/printer.go @@ -149,18 +149,21 @@ func (p *printer) printDefineVars(n *astro.Node) { } } -func (p *printer) printFuncPrelude(componentName string) { +func (p *printer) printFuncPrelude(opts transform.TransformOptions) { if p.hasFuncPrelude { return } + componentName := getComponentName(opts.Pathname) p.addNilSourceMapping() p.println("\n//@ts-ignore") p.println(fmt.Sprintf("const %s = %s(async (%s, $$props, %s) => {", componentName, CREATE_COMPONENT, RESULT, SLOTS)) p.println(fmt.Sprintf("const Astro = %s.createAstro($$Astro, $$props, %s);", RESULT, SLOTS)) + p.println(fmt.Sprintf("Astro.self = %s;", componentName)) p.hasFuncPrelude = true } -func (p *printer) printFuncSuffix(componentName string) { +func (p *printer) printFuncSuffix(opts transform.TransformOptions) { + componentName := getComponentName(opts.Pathname) p.addNilSourceMapping() p.println("});") p.println(fmt.Sprintf("export default %s;", componentName)) diff --git a/internal/printer/printer_test.go b/internal/printer/printer_test.go index 4718ab98a..27ac2b7d5 100644 --- a/internal/printer/printer_test.go +++ b/internal/printer/printer_test.go @@ -9,6 +9,7 @@ import ( "testing" astro "github.com/withastro/compiler/internal" + types "github.com/withastro/compiler/internal/t" "github.com/withastro/compiler/internal/test_utils" "github.com/withastro/compiler/internal/transform" ) @@ -29,7 +30,8 @@ var INTERNAL_IMPORTS = fmt.Sprintf("import {\n %s\n} from \"%s\";\n", strings.J }, ",\n "), "http://localhost:3000/") var PRELUDE = fmt.Sprintf(`//@ts-ignore const $$Component = %s(async ($$result, $$props, %s) => { -const Astro = $$result.createAstro($$Astro, $$props, %s);%s`, CREATE_COMPONENT, SLOTS, SLOTS, "\n") +const Astro = $$result.createAstro($$Astro, $$props, %s); +Astro.self = $$Component;%s`, CREATE_COMPONENT, SLOTS, SLOTS, "\n") var RETURN = fmt.Sprintf("return %s%s", TEMPLATE_TAG, BACKTICK) var SUFFIX = fmt.Sprintf("%s;", BACKTICK) + ` }); @@ -68,6 +70,12 @@ type testcase struct { want want } +type jsonTestcase struct { + name string + source string + want []ASTNode +} + func TestPrinter(t *testing.T) { longRandomString := "" for i := 0; i < 4080; i++ { @@ -1610,3 +1618,73 @@ const items = ["Dog", "Cat", "Platipus"]; }) } } + +func TestPrintToJSON(t *testing.T) { + tests := []jsonTestcase{ + { + name: "basic", + source: `

Hello world!

`, + want: []ASTNode{{Type: "element", Name: "h1", Children: []ASTNode{{Type: "text", Value: "Hello world!"}}}}, + }, + { + name: "expression", + source: `

Hello {world}

`, + want: []ASTNode{{Type: "element", Name: "h1", Children: []ASTNode{{Type: "text", Value: "Hello "}, {Type: "expression", Children: []ASTNode{{Type: "text", Value: "world"}}}}}}, + }, + { + name: "Component", + source: ``, + want: []ASTNode{{Type: "component", Name: "Component"}}, + }, + { + name: "custom-element", + source: ``, + want: []ASTNode{{Type: "custom-element", Name: "custom-element"}}, + }, + { + name: "Doctype", + source: ``, + want: []ASTNode{{Type: "doctype", Value: "html"}}, + }, + { + name: "Comment", + source: ``, + want: []ASTNode{{Type: "comment", Value: "hello"}}, + }, + { + name: "Comment preserves whitespace", + source: ``, + want: []ASTNode{{Type: "comment", Value: " hello "}}, + }, + { + name: "Frontmatter", + source: `--- +const a = "hey" +--- +
{a}
`, + want: []ASTNode{{Type: "frontmatter", Value: "\nconst a = \"hey\"\n"}, {Type: "element", Name: "div", Children: []ASTNode{{Type: "expression", Children: []ASTNode{{Type: "text", Value: "a"}}}}}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // transform output from source + code := test_utils.Dedent(tt.source) + + doc, err := astro.Parse(strings.NewReader(code)) + + if err != nil { + t.Error(err) + } + + root := ASTNode{Type: "root", Children: tt.want} + toMatch := root.String() + + result := PrintToJSON(code, doc, types.ParseOptions{Position: false}) + + if diff := test_utils.ANSIDiff(test_utils.Dedent(string(toMatch)), test_utils.Dedent(string(result.Output))); diff != "" { + t.Error(fmt.Sprintf("mismatch (-want +got):\n%s", diff)) + } + }) + } +} diff --git a/internal/printer/utils.go b/internal/printer/utils.go index c1304c033..6c4c05d30 100644 --- a/internal/printer/utils.go +++ b/internal/printer/utils.go @@ -3,6 +3,8 @@ package printer import ( "regexp" "strings" + + "github.com/iancoleman/strcase" ) func escapeText(src string) string { @@ -13,6 +15,22 @@ func escapeText(src string) string { ) } +func getComponentName(pathname string) string { + if len(pathname) == 0 { + return "$$Component" + } + parts := strings.Split(pathname, "/") + part := parts[len(parts)-1] + if len(part) == 0 { + return "$$Component" + } + basename := strcase.ToCamel(strings.Split(part, ".")[0]) + if basename == "Astro" { + return "$$Component" + } + return strings.Join([]string{"$$", basename}, "") +} + func escapeExistingEscapes(src string) string { return strings.Replace(src, "\\", "\\\\", -1) } diff --git a/internal/sourcemap/sourcemap.go b/internal/sourcemap/sourcemap.go index 69425e5a1..b4250ccb1 100644 --- a/internal/sourcemap/sourcemap.go +++ b/internal/sourcemap/sourcemap.go @@ -607,6 +607,36 @@ func MakeChunkBuilder(inputSourceMap *SourceMap, lineOffsetTables []LineOffsetTa } } +func (b *ChunkBuilder) GetLineAndColumnForLocation(location loc.Loc) []int { + b.prevLoc = location + + // Binary search to find the line + lineOffsetTables := b.lineOffsetTables + count := len(lineOffsetTables) + originalLine := 0 + for count > 0 { + step := count / 2 + i := originalLine + step + if lineOffsetTables[i].byteOffsetToStartOfLine <= location.Start { + originalLine = i + 1 + count = count - step - 1 + } else { + count = step + } + } + originalLine-- + + // Use the line to compute the column + line := &lineOffsetTables[originalLine] + originalColumn := int(location.Start - line.byteOffsetToStartOfLine) + if line.columnsForNonASCII != nil && originalColumn >= int(line.byteOffsetToFirstNonASCII) { + originalColumn = int(line.columnsForNonASCII[originalColumn-int(line.byteOffsetToFirstNonASCII)]) + } + + // 1-based line, 1-based column + return []int{originalLine + 1, originalColumn + 1} +} + func (b *ChunkBuilder) AddSourceMapping(location loc.Loc, output []byte) { if location == b.prevLoc { return diff --git a/internal/t/t.go b/internal/t/t.go new file mode 100644 index 000000000..f81714daa --- /dev/null +++ b/internal/t/t.go @@ -0,0 +1,5 @@ +package t + +type ParseOptions struct { + Position bool +} diff --git a/internal/token.go b/internal/token.go index 19687dd7b..c019dd278 100644 --- a/internal/token.go +++ b/internal/token.go @@ -67,6 +67,24 @@ const ( // AttributeType is the type of an Attribute type AttributeType uint32 +func (t AttributeType) String() string { + switch t { + case QuotedAttribute: + return "quoted" + case EmptyAttribute: + return "empty" + case ExpressionAttribute: + return "expression" + case SpreadAttribute: + return "spread" + case ShorthandAttribute: + return "shorthand" + case TemplateLiteralAttribute: + return "template-literal" + } + return "Invalid(" + strconv.Itoa(int(t)) + ")" +} + const ( QuotedAttribute AttributeType = iota EmptyAttribute @@ -1254,7 +1272,7 @@ func (z *Tokenizer) readTagAttrExpression() { } func (z *Tokenizer) Loc() loc.Loc { - return loc.Loc{Start: z.raw.Start} + return loc.Loc{Start: z.data.Start} } // An expression boundary means the next tokens should be treated as a JS expression diff --git a/lib/compiler/README.md b/lib/compiler/README.md index 74c88ef2f..dedca63ef 100644 --- a/lib/compiler/README.md +++ b/lib/compiler/README.md @@ -12,7 +12,15 @@ npm install @astrojs/compiler ## Usage -_Note: API will change before 1.0! Use at your own discretion._ +_Note: Public APIs are likely to change before 1.0! Use at your own discretion._ + +#### Transform `.astro` to valid TypeScript + +The Astro compiler can convert `.astro` syntax to a TypeScript Module whose default export generates HTML. + +**Some notes**... +- TypeScript is valid `.astro` syntax! The output code may need an additional post-processing step to generate valid JavaScript. +- `.astro` files rely on a server implementation exposed as `astro/internal` in the Node ecosystem. Other runtimes currently need to bring their own rendering implementation and reference it via `internalURL`. This is a pain point we're looking into fixing. ```js import { transform } from '@astrojs/compiler'; @@ -25,6 +33,31 @@ const result = await transform(source, { }); ``` +#### Parse `.astro` and return an AST + +The Astro compiler can emit an AST using the `parse` method. + +**Some notes**... +- Position data is currently incomplete and in some cases incorrect. We're working on it! +- A `TextNode` can represent both HTML `text` and JavaScript/TypeScript source code. +- The `@astrojs/compiler/utils` entrypoint exposes a `walk` function that can be used to traverse the AST. It also exposes the `is` helper which can be used as guards to derive the proper types for each `node`. + +```js +import { parse } from '@astrojs/compiler'; +import { walk, is } from '@astrojs/compiler/utils'; + +const result = await parse(source, { + position: false, // defaults to `true` +}); + +walk(result.ast, (node) => { + // `tag` nodes are `element` | `custom-element` | `component` + if (is.tag(node)) { + console.log(node.name); + } +}) +``` + ## Contributing [CONTRIBUTING.md](./CONTRIBUTING.md) diff --git a/lib/compiler/browser/index.ts b/lib/compiler/browser/index.ts index ef2c9acb2..9b4d1ea9b 100644 --- a/lib/compiler/browser/index.ts +++ b/lib/compiler/browser/index.ts @@ -5,8 +5,13 @@ export const transform: typeof types.transform = (input, options) => { return ensureServiceIsRunning().transform(input, options); }; +export const parse: typeof types.parse = (input, options) => { + return ensureServiceIsRunning().parse(input, options); +}; + interface Service { transform: typeof types.transform; + parse: typeof types.parse; } let initializePromise: Promise | undefined; @@ -52,7 +57,7 @@ const startRunningService = async (wasmURL: string) => { const wasm = await instantiateWASM(wasmURL, go.importObject); go.run(wasm.instance); - const apiKeys = new Set(['transform']); + const apiKeys = new Set(['transform', 'parse']); const service: any = Object.create(null); for (const key of apiKeys.values()) { @@ -63,5 +68,6 @@ const startRunningService = async (wasmURL: string) => { longLivedService = { transform: (input, options) => new Promise((resolve) => resolve(service.transform(input, options || {}))), + parse: (input, options) => new Promise((resolve) => resolve(service.parse(input, options || {}))).then((result: any) => ({ ...result, ast: JSON.parse(result.ast) })), }; }; diff --git a/lib/compiler/browser/utils.ts b/lib/compiler/browser/utils.ts new file mode 100644 index 000000000..e62a94178 --- /dev/null +++ b/lib/compiler/browser/utils.ts @@ -0,0 +1,66 @@ +import { + Node, + ParentNode, + RootNode, + ElementNode, + CustomElementNode, + ComponentNode, + LiteralNode, + ExpressionNode, + TextNode, + CommentNode, + DoctypeNode, + FrontmatterNode, +} from '../shared/ast'; + +export interface Visitor { + (node: Node, parent?: ParentNode, index?: number): void | Promise; +} + +function guard(type: string) { + return (node: Node): node is Type => node.type === type; +} + +export const is = { + parent(node: Node): node is ParentNode { + return Array.isArray((node as any).children); + }, + literal(node: Node): node is LiteralNode { + return typeof (node as any).value === 'string'; + }, + tag(node: Node): node is ElementNode | CustomElementNode | ComponentNode { + return node.type === 'element' || node.type === 'custom-element' || node.type === 'component'; + }, + whitespace(node: Node): node is TextNode { + return node.type === 'text' && node.value.trim().length === 0; + }, + root: guard('root'), + element: guard('element'), + customElement: guard('custom-element'), + component: guard('component'), + expression: guard('expression'), + text: guard('text'), + doctype: guard('doctype'), + comment: guard('comment'), + frontmatter: guard('frontmatter'), +}; + +class Walker { + constructor(private callback: Visitor) {} + async visit(node: Node, parent?: ParentNode, index?: number): Promise { + await this.callback(node, parent, index); + if (is.parent(node)) { + let promises = []; + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + promises.push(this.callback(child, node as ParentNode, i)); + } + await Promise.all(promises); + } + } +} + +export function walk(node: ParentNode, callback: Visitor): void { + const walker = new Walker(callback); + walker.visit(node); +} diff --git a/lib/compiler/deno/astro.wasm b/lib/compiler/deno/astro.wasm index a8cceeb54..905bb4c58 100755 Binary files a/lib/compiler/deno/astro.wasm and b/lib/compiler/deno/astro.wasm differ diff --git a/lib/compiler/node/index.ts b/lib/compiler/node/index.ts index 013bc8ee1..8f1c83db4 100644 --- a/lib/compiler/node/index.ts +++ b/lib/compiler/node/index.ts @@ -7,6 +7,10 @@ export const transform: typeof types.transform = async (input, options) => { return ensureServiceIsRunning().then((service) => service.transform(input, options)); }; +export const parse: typeof types.parse = async (input, options) => { + return ensureServiceIsRunning().then((service) => service.parse(input, options)); +}; + export const compile = async (template: string): Promise => { const { default: mod } = await import(`data:text/javascript;charset=utf-8;base64,${Buffer.from(template).toString('base64')}`); return mod; @@ -14,6 +18,7 @@ export const compile = async (template: string): Promise => { interface Service { transform: typeof types.transform; + parse: typeof types.parse; } let longLivedService: Service | undefined; @@ -40,7 +45,7 @@ const startRunningService = async () => { const wasm = await instantiateWASM(fileURLToPath(new URL('../astro.wasm', import.meta.url)), go.importObject); go.run(wasm.instance); - const apiKeys = new Set(['transform']); + const apiKeys = new Set(['transform', 'parse']); const service: any = Object.create(null); for (const key of apiKeys.values()) { @@ -51,6 +56,7 @@ const startRunningService = async () => { longLivedService = { transform: (input, options) => new Promise((resolve) => resolve(service.transform(input, options || {}))), + parse: (input, options) => new Promise((resolve) => resolve(service.parse(input, options || {}))).then((result: any) => ({ ...result, ast: JSON.parse(result.ast) })), }; return longLivedService; }; diff --git a/lib/compiler/node/utils.ts b/lib/compiler/node/utils.ts new file mode 100644 index 000000000..e62a94178 --- /dev/null +++ b/lib/compiler/node/utils.ts @@ -0,0 +1,66 @@ +import { + Node, + ParentNode, + RootNode, + ElementNode, + CustomElementNode, + ComponentNode, + LiteralNode, + ExpressionNode, + TextNode, + CommentNode, + DoctypeNode, + FrontmatterNode, +} from '../shared/ast'; + +export interface Visitor { + (node: Node, parent?: ParentNode, index?: number): void | Promise; +} + +function guard(type: string) { + return (node: Node): node is Type => node.type === type; +} + +export const is = { + parent(node: Node): node is ParentNode { + return Array.isArray((node as any).children); + }, + literal(node: Node): node is LiteralNode { + return typeof (node as any).value === 'string'; + }, + tag(node: Node): node is ElementNode | CustomElementNode | ComponentNode { + return node.type === 'element' || node.type === 'custom-element' || node.type === 'component'; + }, + whitespace(node: Node): node is TextNode { + return node.type === 'text' && node.value.trim().length === 0; + }, + root: guard('root'), + element: guard('element'), + customElement: guard('custom-element'), + component: guard('component'), + expression: guard('expression'), + text: guard('text'), + doctype: guard('doctype'), + comment: guard('comment'), + frontmatter: guard('frontmatter'), +}; + +class Walker { + constructor(private callback: Visitor) {} + async visit(node: Node, parent?: ParentNode, index?: number): Promise { + await this.callback(node, parent, index); + if (is.parent(node)) { + let promises = []; + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + promises.push(this.callback(child, node as ParentNode, i)); + } + await Promise.all(promises); + } + } +} + +export function walk(node: ParentNode, callback: Visitor): void { + const walker = new Walker(callback); + walker.visit(node); +} diff --git a/lib/compiler/package.json b/lib/compiler/package.json index d68d6d777..a92e01f84 100644 --- a/lib/compiler/package.json +++ b/lib/compiler/package.json @@ -10,7 +10,7 @@ "build": "tsc -p ." }, "main": "./node/index.js", - "types": "./shared/types.d.ts", + "types": "./node", "repository": { "type": "git", "url": "https://github.com/withastro/compiler.git" @@ -21,6 +21,11 @@ "import": "./node/index.js", "default": "./browser/index.js" }, + "./utils": { + "browser": "./browser/utils.js", + "import": "./node/utils.js", + "default": "./browser/utils.js" + }, "./astro.wasm": "./astro.wasm" }, "dependencies": { diff --git a/lib/compiler/shared/ast.ts b/lib/compiler/shared/ast.ts new file mode 100644 index 000000000..25f70a498 --- /dev/null +++ b/lib/compiler/shared/ast.ts @@ -0,0 +1,85 @@ +export type ParentNode = RootNode | ElementNode | ComponentNode | CustomElementNode | ExpressionNode; +export type Node = RootNode | ElementNode | ComponentNode | CustomElementNode | ExpressionNode | TextNode | FrontmatterNode | DoctypeNode | CommentNode; + +export interface Position { + start: Point; + end?: Point; +} +export interface Point { + /** 1-based line number */ + line: number; + /** 1-based column number, per-line */ + column: number; + /** 0-based byte offset */ + offset: number; +} +export interface BaseNode { + type: string; + position?: Position; +} + +export interface ParentLikeNode extends BaseNode { + type: 'element' | 'component' | 'custom-element' | 'expression' | 'root'; + children: Node[]; +} + +export interface LiteralNode extends BaseNode { + type: 'text' | 'doctype' | 'comment' | 'frontmatter'; + value: string; +} + +export interface RootNode extends ParentLikeNode { + type: 'root'; +} + +export interface AttributeNode extends BaseNode { + type: 'attribute'; + kind: 'quoted' | 'empty' | 'expression' | 'spread' | 'shorthand' | 'template-literal'; + name: string; + value: string; +} + +export interface DirectiveNode extends Omit { + type: 'directive'; +} + +export interface TextNode extends LiteralNode { + type: 'text'; +} + +export interface ElementNode extends ParentLikeNode { + type: 'element'; + name: string; + attributes: AttributeNode[]; + directives: DirectiveNode[]; +} + +export interface ComponentNode extends ParentLikeNode { + type: 'component'; + name: string; + attributes: AttributeNode[]; + directives: DirectiveNode[]; +} + +export interface CustomElementNode extends ParentLikeNode { + type: 'custom-element'; + name: string; + attributes: AttributeNode[]; + directives: DirectiveNode[]; +} + +export interface DoctypeNode extends LiteralNode { + type: 'doctype'; +} + +export interface CommentNode extends LiteralNode { + type: 'comment'; +} + +export interface FrontmatterNode extends LiteralNode { + type: 'frontmatter'; +} + +export interface ExpressionNode extends ParentLikeNode { + type: 'expression'; +} diff --git a/lib/compiler/shared/types.ts b/lib/compiler/shared/types.ts index cbd03300f..e6b722104 100644 --- a/lib/compiler/shared/types.ts +++ b/lib/compiler/shared/types.ts @@ -1,8 +1,16 @@ +import { RootNode } from './ast'; +export * from './ast'; + export interface PreprocessorResult { code: string; map?: string; } +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ParseOptions { + position?: boolean; +} + export interface TransformOptions { internalURL?: string; site?: string; @@ -36,6 +44,10 @@ export interface TransformResult { map: string; } +export interface ParseResult { + ast: RootNode; +} + // This function transforms a single JavaScript file. It can be used to minify // JavaScript, convert TypeScript/JSX to JavaScript, or convert newer JavaScript // to older JavaScript. It returns a promise that is either resolved with a @@ -45,6 +57,8 @@ export interface TransformResult { // Works in browser: yes export declare function transform(input: string, options?: TransformOptions): Promise; +export declare function parse(input: string, options?: ParseOptions): Promise; + // This configures the browser-based version of astro. It is necessary to // call this first and wait for the returned promise to be resolved before // making other API calls when using astro in the browser. diff --git a/lib/compiler/test/component-name.test.mjs b/lib/compiler/test/component-name.test.mjs new file mode 100644 index 000000000..025debb31 --- /dev/null +++ b/lib/compiler/test/component-name.test.mjs @@ -0,0 +1,16 @@ +/* eslint-disable no-console */ + +import { transform } from '@astrojs/compiler'; + +async function run() { + const result = await transform(`
Hello world!
`, { + sourcemap: true, + pathname: '/src/components/Cool.astro', + }); + + if (!result.code.includes('export default $$Cool')) { + throw new Error(`Expected component export to be named "Cool"!`); + } +} + +await run(); diff --git a/lib/compiler/test/parse.test.mjs b/lib/compiler/test/parse.test.mjs new file mode 100644 index 000000000..cff4bf02a --- /dev/null +++ b/lib/compiler/test/parse.test.mjs @@ -0,0 +1,42 @@ +/* eslint-disable no-console */ + +import { parse } from '@astrojs/compiler'; +import { walk, is } from '@astrojs/compiler/utils'; + +const src = `--- +let value = 'world'; +--- + +

Hello {value}

+`; + +async function run() { + const result = await parse(src); + + if (typeof result !== 'object') { + throw new Error(`Expected "parse" to return an object!`); + } + if (result.ast.type !== 'root') { + throw new Error(`Expected "ast" root node to be of type "root"`); + } + const [frontmatter, _, element] = result.ast.children; + if (frontmatter.type !== 'frontmatter') { + throw new Error(`Expected first child node to be of type "frontmatter"`); + } + if (element.type !== 'element') { + throw new Error(`Expected third child node to be of type "element"`); + } + + walk(result.ast, (node) => { + if (is.tag(node)) { + if (node.name !== 'h1') { + throw new Error(`Expected element to be "

"`); + } + if (!node.directives || (Array.isArray(node.directives) && !(node.directives.length >= 1))) { + throw new Error(`Expected client:load directive to be on "

"`); + } + } + }); +} + +await run(); diff --git a/lib/compiler/test/test.mjs b/lib/compiler/test/test.mjs index c9b4b1985..c6f8899ca 100644 --- a/lib/compiler/test/test.mjs +++ b/lib/compiler/test/test.mjs @@ -2,7 +2,9 @@ import './basic.test.mjs'; import './body-fragment.test.mjs'; import './body-expression.test.mjs'; import './component-only.test.mjs'; +import './component-name.test.mjs'; import './empty-style.test.mjs'; import './output.test.mjs'; import './script-fragment.test.mjs'; import './top-level-expression.test.mjs'; +import './parse.test.mjs'; diff --git a/lib/compiler/utils.d.ts b/lib/compiler/utils.d.ts new file mode 100644 index 000000000..644c24206 --- /dev/null +++ b/lib/compiler/utils.d.ts @@ -0,0 +1 @@ +export * from './node/utils';