diff --git a/README.md b/README.md index a26a0b9..aa5196e 100644 --- a/README.md +++ b/README.md @@ -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, @@ -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. diff --git a/examples/01-simple.tson b/examples/01-simple.tson index 1576534..38bcdcc 100644 --- a/examples/01-simple.tson +++ b/examples/01-simple.tson @@ -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, diff --git a/examples/02-explicit-export.tson b/examples/02-explicit-export.tson new file mode 100644 index 0000000..7de999e --- /dev/null +++ b/examples/02-explicit-export.tson @@ -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 +// } \ No newline at end of file diff --git a/examples/02-implicit-export.tson b/examples/02-implicit-export.tson new file mode 100644 index 0000000..55c07ae --- /dev/null +++ b/examples/02-implicit-export.tson @@ -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', +// } \ No newline at end of file diff --git a/examples/03-spread-override.tson b/examples/03-spread-override.tson index 475c584..ae16222 100644 --- a/examples/03-spread-override.tson +++ b/examples/03-spread-override.tson @@ -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 diff --git a/examples/02-typecheck.tson b/examples/04-typecheck.tson similarity index 100% rename from examples/02-typecheck.tson rename to examples/04-typecheck.tson diff --git a/examples/04-function-with-interpolation.tson b/examples/05-function-with-interpolation.tson similarity index 100% rename from examples/04-function-with-interpolation.tson rename to examples/05-function-with-interpolation.tson diff --git a/examples/05-import.tson b/examples/06-import.tson similarity index 100% rename from examples/05-import.tson rename to examples/06-import.tson diff --git a/internal/tsembed/eval.go b/internal/tsembed/eval.go index fe1eddb..7148e46 100644 --- a/internal/tsembed/eval.go +++ b/internal/tsembed/eval.go @@ -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 diff --git a/internal/tsembed/plugin.go b/internal/tsembed/plugin.go new file mode 100644 index 0000000..7506c84 --- /dev/null +++ b/internal/tsembed/plugin.go @@ -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 +} diff --git a/internal/tsembed/tsembed.go b/internal/tsembed/tsembed.go index fae111d..8485757 100644 --- a/internal/tsembed/tsembed.go +++ b/internal/tsembed/tsembed.go @@ -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,