Reference: the Go port
Rendered from
docs/reference-go.md
in the generator repository — where a correction belongs, and where the
test suite runs every example on this page or states why it does not.
github.com/jostraca/jostraca/go is a maintained port of the canonical
TypeScript package. It aims at byte-identical output for the same
logical input, and where Go idiom forced a different surface, this page
says so.
go get github.com/jostraca/jostraca/go
TypeScript is the source of truth. When the two disagree, TypeScript wins and Go is the one that changes—see the explanation for why that rule and not a better-looking one.
The Go snippets on this page are not executed by the documentation suite, which runs JavaScript; each carries a skip naming the Go test that pins it instead. The first one below was compiled and run to produce the output shown.
Constructing and generating#
New(...Option) *J
(*J).Generate(Options, func(*J)) (Result, error)
New seeds global options. Component methods must be called on the
*J passed into the Generate callback, not on the value New
returned.
package main
import (
"fmt"
jostraca "github.com/jostraca/jostraca/go"
)
func main() {
j := jostraca.New(
jostraca.WithFolder("./out"),
jostraca.WithModel(map[string]any{
"app": map[string]any{"name": "acme"},
}),
)
res, err := j.Generate(jostraca.Options{}, func(j *jostraca.J) {
j.Project(jostraca.ProjectProps{Folder: "acme"}, func(j *jostraca.J) {
j.File("package.json", func(j *jostraca.J) {
j.Content("{ \"name\": \"$$app.name$$\" }\n")
})
j.Folder("src", func(j *jostraca.J) {
j.File("index.js", func(j *jostraca.J) {
j.Content("console.log(\"$$app.name$$\")\n")
})
})
})
})
if err != nil {
panic(err)
}
fmt.Println("written:", res.Files.Written)
}
It prints written: [out/acme/package.json out/acme/src/index.js], and
out/acme/package.json holds { "name": "acme" }.
Note the shadowing: each callback receives a *J bound to the node it
is inside. That is what replaces the ambient AsyncLocalStorage the
TypeScript components use, and it is why the parameter is named j
again in every closure.
Components#
Each component has a positional convenience method and, where there is
more than one prop, a …P variant taking a props struct.
| method | props struct | fields |
|---|---|---|
Project(ProjectProps, body) | ProjectProps | Name, Folder |
Folder(name, body) | — | — |
File(name, body) / FileP(FileProps, body) | FileProps | Name, Exclude any, Mode fs.FileMode |
Content(src) / ContentP(ContentProps) | ContentProps | Src, Name, Indent any, Replace map[string]any, Extra map[string]any |
Line(src) / LineP(ContentProps) | as Content | |
Fragment(FragmentProps, body) / FragmentP | FragmentProps | From, Indent, Replace, Eject |
Slot(name, body) / SlotP(SlotProps, body) | SlotProps | Name |
Inject(name, body) / InjectP(InjectProps, body) | InjectProps | Name, Markers, Exclude |
Copy(CopyProps) | CopyProps | From, To, Exclude, Replace |
List(items, body) / ListP(ListProps, body) | ListProps | Item, Indent, NoLine |
Cmp(name, fn) | — | a user component |
List’s body signature is func(j *J, it ListItemProps), mirroring the
{item, indent, replace} object TypeScript hands each child.
ListItemProps carries Item any, Indent any and
Replace map[string]any. The last two are meant to be passed straight
through—neither does anything on its own:
j.ListP(ListProps{Item: items, Indent: " "}, func(j *J, it ListItemProps) {
j.ContentP(ContentProps{
Src: "{item.name}: {item.role}\n",
Indent: it.Indent,
Replace: it.Replace,
})
})
{item.path} resolves with GetX, so nested paths work. The three quiet
limits are the same as TypeScript’s: a bare {item}, a $-suffixed key
({item.index$}), and an unresolved path all yield the empty string,
unlike $$path$$, which is left in place.
ListProps has no Replace field, matching TypeScript, where List’s
own replace prop is accepted and never used.
Semantics follow the component reference unless the deviations below say otherwise.
Options#
Options is a struct, and New also takes functional options:
WithFolder(string) WithModel(map[string]any) WithMeta(map[string]any)
WithLog(Log) WithDebug(string) WithMem()
WithVol(map[string][]byte) WithFS(FS)
WithNow(func() int64) WithExisting(Existing)
WithControl(Control) WithBuild(bool)
OptionsFromMap builds an Options from a decoded JSON or YAML map,
for configuration that arrives as data.
WithMem and WithVol#
Mem switches an in-memory filesystem on and Vol seeds it, matching
TypeScript’s {mem: true} and vol:
j := jostraca.New(
jostraca.WithMem(),
jostraca.WithVol(map[string][]byte{"/tpl/x.txt": []byte("hi")}),
jostraca.WithFolder("/out"),
)
res, err := j.Generate(jostraca.Options{}, root)
// nothing touched the real filesystem;
// res.Vol() holds the generated tree, res.FS() the provider.
Three rules, all shared with TypeScript:
VolwithoutMemdoes nothing.Memis the switch.- An explicit provider beats both.
WithFS(mem)wins overWithMem(), asopts.fswins there. - A global
Memis reused acrossGeneratecalls, so a second run regenerates over the first run’s output—unless that call passes its ownVol, which seeds a fresh volume.
An explicit provider is still the right choice when a test wants to seed the filesystem by writing into it:
mem := jostraca.NewMemFS()
j := jostraca.New(jostraca.WithFS(mem), jostraca.WithFolder("/out"))
res, err := j.Generate(jostraca.Options{}, root)
// mem.ReadFile("/out/a.txt") returns the generated bytes.
Until v0.35.0 both options were inert: WithMem() ran against the real
filesystem and returned Vol and FS as nil with no error, so a
TypeScript in-memory test translated across by keeping mem and vol
passed while writing to the working directory. See #37.
Per-call Cmp and Name are dropped#
The option merge copies Folder, Meta, FS, Now, Log, Debug,
Model, Build, Mem, Vol, Existing, Control and Exclude from
the Generate call. It does not copy Cmp or Name, and there is
no WithCmp. Verified with a Copy and an ignore pattern:
| where the ignore list was set | what was copied |
|---|---|
Generate(Options{Cmp: …}) | keep.txt and skip.log—ignored |
a global option on New | keep.txt only—honoured |
So the only route to Options.Cmp.Copy.Ignore today is a hand-written
option closure passed to New:
j := jostraca.New(
jostraca.WithFolder("./out"),
func(o *jostraca.Options) { o.Cmp = cmp },
)
TypeScript has no equivalent hole: cmp is an ordinary option and the
per-call value reaches the copy operation.
Two fields invert their TypeScript counterparts so that Go’s zero value matches the TypeScript default:
Control.NoDuplicateinvertscontrol.duplicate.ListProps.NoLineinvertsprops.line === false.
The existing flags are pointers (*bool) so that “unset” and
“explicitly false” stay distinguishable through the option merge:
type ExistingTxt struct { Write, Preserve, Present, Diff, Merge *bool }
type ExistingBin struct { Write, Preserve, Present *bool }
The result#
type Result struct {
When int64
Files Files
Audit func() Audit
Vol func() map[string][]byte
FS func() FS
}
type Files struct {
Preserved, Written, Presented, Diffed, Merged, Conflicted, Unchanged []string
}
Audit is []AuditEntry, each {Tag string; Data map[string]any}.
Vol snapshots the volume: every file’s content, plus a nil entry for
every empty directory. A directory appears only while it is empty—otherwise
its children stand for it—mirroring TypeScript’s
vol.toJSON(), which records one as null. An empty file is a non-nil
zero-length slice, so a caller that wants files alone should test
v != nil rather than len(v) > 0.
Utilities#
The same helper surface, capitalised, plus narrower variants where Go cannot overload:
| TypeScript | Go |
|---|---|
each | Each(subject, EachSpec, apply), plus EachF, EachI, EachKV, EachKVRaw |
get | Get(root, path) |
getx | GetX(root, path any), GetXS(root, string), GetXPath(root, []string) |
camelify / snakify / kebabify / partify | Camelify, Snakify, Kebabify, Partify |
names | Names(base, name, prop...), NamesP(base, name, prop) |
template | Template(src, model, *TemplateSpec), TemplateF, TemplateR |
indent | Indent(src, ind any) |
isbinext / isbincontent | IsBinExt, IsBinContent |
deep | Deep(dst, srcs...) |
omap | OMap(m) [][2]any |
cmap / vmap | CMap, VMap |
DiffUtil.merge | Merge(generated, baseline, existing, DiffSpec) MergeResult |
DiffUtil.diff | Diff(generated, existing, DiffSpec) DiffResult |
DiffUtil.hasConflicts | HasConflicts, HasConflictsLabel |
DiffUtil.lines / lcs / alignLcs / hunks | Lines, LCS, AlignLCS, Hunks |
OMap returns an ordered pair list rather than a map, because a Go map
has no order to return. That is also why Each, CMap and VMap sort
object keys on both sides: sorted is the only order the two stacks
can agree on.
Deviations from TypeScript#
Every difference below is deliberate, and each is either Go idiom or a consequence of the language.
go/README.md carries the same set for a reader who is already in the
repository. The two lists are not line-for-line: this one groups a few
items that one keeps separate, and covers others in the preceding sections
rather than as bullets. Neither omits anything the other has.
Shape of the API
- Components are methods on
*J, not free functions. Receiver-shadowing closures replaceAsyncLocalStorage. Generatereturns(Result, error)rather than throwing.Optionsis a typed struct with functional options, plusOptionsFromMap.
Inverted flags, so the Go zero value is the TypeScript default
EachSpec.Rawinvertsoval;EachSpec.NoMarkinvertsmark.ListProps.NoLineinvertsline.Control.NoDuplicateinvertsduplicate.
EachSpec also has no call flag, and its Sort is a boolean: there
is no sort-by-property in Go.
Language limits
- Go’s
regexpis RE2 and has no lookbehind, so a user-supplied regular expression key containing(?<=…)is rejected at compile time. - A template value that is an integer wider than 2^53 keeps its exact
value in Go and loses precision in TypeScript, where every number is a
float64. Everything afloat64holds exactly formats identically on both stacks, and that is pinned by a test.
Behavioural differences worth knowing
Deepbuilds a new map or slice instead of mutating and returning its first argument. Callers that use the return value see no difference; callers relying on the aliasing would. The merge semantics themselves match,nilincluded: anilargument is skipped, as TypeScript skipsundefined, while anilmap value or slice element overwrites, as TypeScript’snulldoes. Only[]anymerges by index; a typed slice such as[]stringtakes the right-wins path, as does any value carrying a type of its own—which is TypeScript’s custom-constructor rule.- The option merge drops per-call
CmpandName, socmp.Copy.ignorehas to be set onNew. Described earlier, with what to do instead, and ingo/README.md’s deviations list too. - A user component that wraps a
Slotis broken in TypeScript—the slot name is never collected and the marker survives verbatim—and Go matches it.J.Cmpallocates akind: 'none'node and passes through the Fragment filter, as TypeScript’scmp()does, so a user component used as a directFragmentchild behaves identically on both sides: the filter rejects it on the scan, an unnamed<[SLOT]>marker accepts it once, and without such a marker the build fails on both. Until v0.35.0 Go ran the body inline with no node, so the filter never saw it—three runs against TypeScript’s zero, and a silent body wrote the file where TypeScript aborted. See #29. - A binary single-file
Copynested inside aFilesplices its raw bytes into the enclosing file here; TypeScript contributes nothing and logs it. A Go string is a byte string; TypeScript’s copy content is aBuffer, and joining one into a JS string UTF-8 decodes it, so every byte that is not valid UTF-8 would become U+FFFD. TypeScript writes nothing rather than a corrupted approximation. A text copy splices identically on both sides, and the copy itself is written intact either way. - A template macro resolving to a
[]byterenders as Go’s[104 105], and to a pointer as&{1 x}. Every other composite—maps, slices, arrays and structs, of any element type—JSONifies with keys sorted at every depth, matching TypeScript. Neither exception has an obvious right answer:encoding/jsonrenders a byte slice as base64 while TypeScript renders aBufferthrough itstoJSONas{"type":"Buffer","data":[…]}, and dereferencing a pointer raises its own questions about nil and about value-versus-reference. Both are pinned so they cannot change by accident. ListItemProps.Itemis the raw item; TypeScript’sprops.itemis each-wrapped, so a scalar arrives there as{val$, index$}.Listiterates withRawhere and witheach’s default annotation in TypeScript. The{item.path}macro is unaffected:getxcannot address a$-suffixed key on either stack, so{item.val$}and{item.index$}yield the empty string in TypeScript too, and the item argument is the documented route to a scalar on both sides.PointUtilis not ported.
Consequences of Go’s zero values
- A per-call
Controlcannot clear a global one.Controlis a value struct, soControl{Dryrun: false}is indistinguishable from “not supplied” and the global wins. TypeScript can express “globally dry, but write for this call”. Closing it needs pointer fields. FileProps.Modeof0means “unset”, so the file keeps its default0644. TypeScript treatsmode: 0as a request and writes an unreadable0o000file.
Permission bits
- Special bits use Go’s encoding rather than POSIX octal.
fs.FileModekeeps setuid atfs.ModeSetuid, not at0o4000, so TypeScript’smode: 0o4755is written0o755 | fs.ModeSetuidhere. The resulting file is identical; a literal0o4755is not setuid in Go and lands as0755.
Known gaps, tracked
- Template replace keys of equal length tie-break alphabetically here and by
declaration order in TypeScript, which sorts insertion-ordered
Object.keys()with a stable sort. A Go map has no declaration order to reproduce. The two agree whenever declaration order is alphabetical. - An eject marker given as a slash-wrapped string (
"/START.*/") is compiled as a regular expression here and matched literally by TypeScript. Passing a real regular-expression value behaves the same on both sides. TypeScript is canonical, so Go is the side to change.
Concurrency#
Generate calls are isolated from one another: the builder state hangs
off the *J handed to the callback rather than off any process-global,
so two generates can run at once without seeing each other’s trees.
go/concurrency_test.go pins that.
This is the one place the Go design is plainly better than the
TypeScript one, which keeps its AsyncLocalStorage on global so that
two copies of the package interoperate.
Parity, and where it is pinned#
Behaviour shared by both stacks lives in
test/spec/:
language-neutral TSV cases that ts/test/spec.test.ts and
go/spec_test.go both read. An unknown case
is a hard failure on both sides, so a row cannot be silently skipped by
one.
Beyond that, go/testdata/parity/ holds whole-scenario fixtures
generated from canonical TypeScript, and CI regenerates them and fails
on any diff—so a TypeScript change cannot leave the Go expectations
stale.
Build and test#
cd go
go build ./...
go test ./...
From the repository root, make all builds and tests both stacks.