Skip to content

Commit

Permalink
[tyson] Implicitly export a single top-level object literal (#72)
Browse files Browse the repository at this point in the history
  • Loading branch information
loreto committed Jul 5, 2023
1 parent 667194d commit 41b4ac8
Show file tree
Hide file tree
Showing 11 changed files with 144 additions and 12 deletions.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Here's an example `.tson` file:

```typescript
// example.tson
export default {
{
// Single-line comments are supported
array_field: [1, 2, 3],
boolean_field: true,
Expand Down Expand Up @@ -70,12 +70,14 @@ type Config = {
required_field: string
// This field is optional
optional_field?: number
};
}

// When there are multiple expressions in a file, we need to `export default` the one
// that should be evaluated as JSON:
export default {
optional_field: "1", // Type error: expected number, got string
rquired_field: 'bar', // This typo will be caught by the TypeScript compiler
} : Config
} satisfies Config
```

**Programmable**: You can generate configuration programmatically.
Expand Down
2 changes: 1 addition & 1 deletion examples/01-simple.tson
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
//
// This is the same example as in the README.

export default {
{
// Single-line comments are supported
array_field: [1, 2, 3],
boolean_field: true,
Expand Down
22 changes: 22 additions & 0 deletions examples/02-explicit-export.tson
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// When there are several expressions in a file, how does TySON choose which one
// to evaluate as JSON?
// Simple: it always uses the default export

var one = {
one: 1
}

var two = {
two: 2
}

var three = {
three: 3
}

export default two; // This is the default export

// This file evaluates to the following JSON:
// {
// "two": 2
// }
20 changes: 20 additions & 0 deletions examples/02-implicit-export.tson
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// If TySON always uses the default export, how does it handle simple JSON-like cases
// where there are no exports?
//
// The answer is that while TySON uses standard TypeScript syntax, it does apply
// a single non-standard transformation to the input file: if a file has a single
// top-level object literal, then that object literal is implicitly treated as the
// default export.
//
// This transformation is important, because it makes it possible to read standard
// .json files as valid .tson files.

// In this example, the following object literal is interpreted as the default export:
{
foo: 'bar',
}

// In other words, it's the same as if we had written:
// export default {
// foo: 'bar',
// }
2 changes: 2 additions & 0 deletions examples/03-spread-override.tson
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ var simple = {
age: 21,
}

// Since we have multiple expressions in this file, we must be explicit with
// "export default"
export default {
...simple, // Spread operator is supported
age: 31, // Override the age field
Expand Down
File renamed without changes.
File renamed without changes.
8 changes: 6 additions & 2 deletions internal/tsembed/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ func evalJS(code string) (goja.Value, error) {
if err != nil {
return nil, err
}
globals := vm.Get(globalsName).ToObject(vm)
val := globals.Get("default")
globals := vm.Get(globalsName)
// Return null if the globals variable is not defined.
if globals == nil || goja.IsNull(globals) || goja.IsUndefined(globals) {
return nil, nil
}
val := globals.ToObject(vm).Get("default")
// Right now we return a goja value, but this might have to change if we
// decide to move to V8
return val, nil
Expand Down
84 changes: 84 additions & 0 deletions internal/tsembed/plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package tsembed

import (
"bytes"
"os"
"strings"
"text/scanner"

"github.com/evanw/esbuild/pkg/api"
)

var TsonTransform = api.Plugin{
Name: "tsonTransform",
Setup: func(build api.PluginBuild) {
build.OnLoad(
api.OnLoadOptions{Filter: `\.tson$`},
loadTSON,
)
},
}

func loadTSON(args api.OnLoadArgs) (api.OnLoadResult, error) {
original, err := os.ReadFile(args.Path)
if err != nil {
return api.OnLoadResult{}, err
}

offset := findImplicitExport(original)
var builder strings.Builder

if offset != -1 {
builder.Write(original[:offset])
builder.WriteString("export default ")
builder.Write(original[offset:])
} else {
builder.Write(original)
}

result := builder.String()
return api.OnLoadResult{
Contents: &result,
Loader: api.LoaderTS,
}, nil
}

// If there are no exports, but there is an top-level object, we identify it
// as an object that should be implicitly exported.
func findImplicitExport(data []byte) int {
buf := bytes.NewReader(data)
var tokenizer scanner.Scanner
tokenizer.Init(buf)
tokenizer.Error = func(_ *scanner.Scanner, _ string) {} // ignore errors

var offset = -1
nestingLevel := 0
existingObject := false
for tok := tokenizer.Scan(); tok != scanner.EOF; tok = tokenizer.Scan() {
switch token := tokenizer.TokenText(); token {
case "{":
// We found a top-level object:
if nestingLevel == 0 {
if !existingObject {
// This is the first one we find, so save the offset as we might want to
// implicitly export it.
offset = tokenizer.Offset
existingObject = true
} else {
// If we've found more than one top-level object, we don't want to implicitly
// export any of them.
return -1
}
}
nestingLevel++
case "}":
nestingLevel--
default:
// We've run into another expression, so we don't want to implicitly export anything.
if nestingLevel == 0 {
return -1
}
}
}
return offset
}
10 changes: 4 additions & 6 deletions internal/tsembed/tsembed.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,10 @@ func Build(entrypoint string) ([]byte, error) {
bundle := api.Build(api.BuildOptions{
EntryPoints: []string{entrypoint},

Bundle: true,
Charset: api.CharsetUTF8,
GlobalName: globalsName,
Loader: map[string]api.Loader{
".tson": api.LoaderTS,
},
Bundle: true,
Charset: api.CharsetUTF8,
GlobalName: globalsName,
Plugins: []api.Plugin{TsonTransform},
Platform: api.PlatformBrowser,
Target: api.ES2015, // ES6 == ES2015
TsconfigRaw: tsConfig,
Expand Down

0 comments on commit 41b4ac8

Please sign in to comment.