Skip to content

Commit d1d56ec

Browse files
authored
feat: input file mapping (#313)
1 parent c3980e0 commit d1d56ec

61 files changed

Lines changed: 8865 additions & 234 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/build.yml

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,20 @@ jobs:
8484
- name: Install brotli
8585
run: sudo apt-get update && sudo apt-get install -y brotli
8686

87-
- name: Build WASM
88-
run: GOOS=js GOARCH=wasm go build -o boilerplate.wasm -ldflags "-s -w" ./cmd/wasm/
87+
- name: Build WASM (lite)
88+
run: GOOS=js GOARCH=wasm go build -o boilerplate.wasm -ldflags "-s -w" ./cmd/wasm/lite/
89+
90+
- name: Build WASM (full)
91+
run: GOOS=js GOARCH=wasm go build -o boilerplate-full.wasm -ldflags "-s -w" ./cmd/wasm/full/
8992

9093
- name: Compress WASM with brotli
9194
run: |
9295
brotli --best boilerplate.wasm -o boilerplate.wasm.br
93-
echo "Uncompressed: $(wc -c < boilerplate.wasm | tr -d ' ') bytes"
94-
echo "Compressed: $(wc -c < boilerplate.wasm.br | tr -d ' ') bytes"
96+
brotli --best boilerplate-full.wasm -o boilerplate-full.wasm.br
97+
echo "Lite uncompressed: $(wc -c < boilerplate.wasm | tr -d ' ') bytes"
98+
echo "Lite compressed: $(wc -c < boilerplate.wasm.br | tr -d ' ') bytes"
99+
echo "Full uncompressed: $(wc -c < boilerplate-full.wasm | tr -d ' ') bytes"
100+
echo "Full compressed: $(wc -c < boilerplate-full.wasm.br | tr -d ' ') bytes"
95101
96102
- name: Copy wasm_exec.js
97103
run: cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" .
@@ -103,4 +109,6 @@ jobs:
103109
path: |
104110
boilerplate.wasm
105111
boilerplate.wasm.br
112+
boilerplate-full.wasm
113+
boilerplate-full.wasm.br
106114
wasm_exec.js

.gitignore

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ vendor
33
.terragrunt
44
.DS_Store
55
*.iml
6+
.claude
67
boilerplate
7-
boilerplate.wasm
8-
boilerplate.wasm.br
8+
boilerplate*.wasm
9+
boilerplate*.wasm.br
910
wasm_exec.js
11+
/bin
1012
/wasm

Makefile

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
GOFMT_FILES?=$$(find . -name '*.go' -not -path "./examples/*")
2+
WASM_DIR?=examples/wasm
23

34
default: build
45

@@ -28,22 +29,30 @@ fmt:
2829
@echo "Running source files through gofmt..."
2930
gofmt -w $(GOFMT_FILES)
3031

31-
build-wasm:
32-
GOOS=js GOARCH=wasm go build -o examples/wasm/boilerplate.wasm -ldflags "-s -w" ./cmd/wasm/
32+
build-wasm-lite:
33+
GOOS=js GOARCH=wasm go build -o $(WASM_DIR)/boilerplate.wasm -ldflags "-s -w" ./cmd/wasm/lite/
34+
35+
build-wasm-full:
36+
GOOS=js GOARCH=wasm go build -o $(WASM_DIR)/boilerplate-full.wasm -ldflags "-s -w" ./cmd/wasm/full/
37+
38+
build-wasm: build-wasm-lite build-wasm-full
3339

3440
compress-wasm: build-wasm
3541
@command -v brotli >/dev/null 2>&1 || { echo "Error: brotli CLI not found. Install with: brew install brotli (macOS) or apt-get install brotli (Linux)"; exit 1; }
36-
brotli --best --force examples/wasm/boilerplate.wasm -o examples/wasm/boilerplate.wasm.br
37-
@echo "Uncompressed: $$(wc -c < examples/wasm/boilerplate.wasm | tr -d ' ') bytes"
38-
@echo "Compressed: $$(wc -c < examples/wasm/boilerplate.wasm.br | tr -d ' ') bytes"
42+
brotli --best --force $(WASM_DIR)/boilerplate.wasm -o $(WASM_DIR)/boilerplate.wasm.br
43+
brotli --best --force $(WASM_DIR)/boilerplate-full.wasm -o $(WASM_DIR)/boilerplate-full.wasm.br
44+
@echo "Lite uncompressed: $$(wc -c < $(WASM_DIR)/boilerplate.wasm | tr -d ' ') bytes"
45+
@echo "Lite compressed: $$(wc -c < $(WASM_DIR)/boilerplate.wasm.br | tr -d ' ') bytes"
46+
@echo "Full uncompressed: $$(wc -c < $(WASM_DIR)/boilerplate-full.wasm | tr -d ' ') bytes"
47+
@echo "Full compressed: $$(wc -c < $(WASM_DIR)/boilerplate-full.wasm.br | tr -d ' ') bytes"
3948

4049
copy-wasm-exec:
41-
cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" examples/wasm/
50+
cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" $(WASM_DIR)/
4251

4352
wasm: compress-wasm copy-wasm-exec
44-
cp examples/wasm/wasm_exec.js examples/wasm/boilerplate.wasm examples/wasm/boilerplate.wasm.br examples/wasm/browser/
45-
cp examples/wasm/wasm_exec.js examples/wasm/boilerplate.wasm.br examples/wasm/node/
53+
cp $(WASM_DIR)/wasm_exec.js $(WASM_DIR)/boilerplate.wasm $(WASM_DIR)/boilerplate.wasm.br $(WASM_DIR)/browser/
54+
cp $(WASM_DIR)/wasm_exec.js $(WASM_DIR)/boilerplate.wasm.br $(WASM_DIR)/boilerplate-full.wasm.br $(WASM_DIR)/node/
4655
@echo "WASM build complete:"
47-
@ls -lh examples/wasm/boilerplate.wasm examples/wasm/boilerplate.wasm.br
56+
@ls -lh $(WASM_DIR)/boilerplate.wasm $(WASM_DIR)/boilerplate.wasm.br $(WASM_DIR)/boilerplate-full.wasm $(WASM_DIR)/boilerplate-full.wasm.br
4857

49-
.PHONY: lint test default update-lint-config build-wasm compress-wasm copy-wasm-exec wasm
58+
.PHONY: lint test default update-lint-config build-wasm build-wasm-lite build-wasm-full compress-wasm copy-wasm-exec wasm

cli/boilerplate_cli.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ func CreateBoilerplateCli() *cli.App {
5858
app.UsageText = "boilerplate [OPTIONS]"
5959
app.Version = version.GetVersion()
6060
app.Action = runApp
61+
app.Commands = []*cli.Command{newInputsCommand()}
6162

6263
app.Flags = []cli.Flag{
6364
&cli.StringFlag{

cli/inputs_cmd.go

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"os"
9+
10+
"github.com/urfave/cli/v2"
11+
12+
"github.com/gruntwork-io/boilerplate/getterhelper"
13+
"github.com/gruntwork-io/boilerplate/inputs"
14+
"github.com/gruntwork-io/boilerplate/options"
15+
"github.com/gruntwork-io/boilerplate/pkg/logging"
16+
"github.com/gruntwork-io/boilerplate/variables"
17+
)
18+
19+
const inputsMapHelpText = `Usage: boilerplate inputs map [OPTIONS]
20+
21+
Analyze the template at --template-url and print a JSON object describing which
22+
output files are affected by each declared input variable. The command never
23+
renders or writes any output files; it parses each template's AST to compute
24+
the mapping.
25+
26+
The output schema is:
27+
28+
{
29+
"inputs": {
30+
"<template_path>:<input_name>": {
31+
"name": "<input_name>",
32+
"declared_in": "<template_path>",
33+
"files": ["relative/path/to/file1", ...],
34+
"type": "string|bool|int|list|map|...",
35+
"description": "<from boilerplate.yml if present>"
36+
}
37+
},
38+
"files": {
39+
"relative/path/to/file1": ["<template_path>:<input_name>", ...]
40+
},
41+
"sources": {
42+
"relative/path/to/file1": "<absolute_path_to_source_template_file>"
43+
},
44+
"errors": [
45+
{ "kind": "undeclared_variable", "template": "...", "name": "...", "file": "..." }
46+
]
47+
}
48+
49+
Keys in "inputs" are fully-qualified as <template_path>:<input_name>, where
50+
<template_path> is "." for the root template and the dependency's
51+
output-folder path (relative to the root output) for nested templates.
52+
53+
Keys in "sources" mirror those in "files": each output path maps to the
54+
absolute filesystem path of the source template file that produces it.
55+
Templates resolved via go-getter (git::, https://, ...) live under a temp
56+
dir for the duration of the command; consumers needing the body should
57+
read it before the process exits. Output paths whose filename template
58+
failed to render are omitted from "sources" — the corresponding
59+
"filename_render" entry in "errors" signals that the path is dynamic.
60+
61+
The command exits with a non-zero status only on unrecoverable parse failures.
62+
Soft errors (e.g., a referenced variable that is not declared in any
63+
boilerplate.yml in scope) appear in the "errors" array and do not change the
64+
exit code.`
65+
66+
func newInputsCommand() *cli.Command {
67+
return &cli.Command{
68+
Name: "inputs",
69+
Usage: "Inspect declared input variables for a template.",
70+
Subcommands: []*cli.Command{
71+
{
72+
Name: "map",
73+
Usage: "Print a JSON map of declared inputs to the output files they affect.",
74+
Description: inputsMapHelpText,
75+
Action: runInputsMap,
76+
Flags: []cli.Flag{
77+
&cli.StringFlag{
78+
Name: options.OptTemplateURL,
79+
Usage: "Generate the input mapping for the template at `URL`. Same resolution rules as `boilerplate template`.",
80+
Required: true,
81+
},
82+
&cli.StringSliceFlag{
83+
Name: options.OptVar,
84+
Usage: "Use `NAME=VALUE` to set variable NAME to VALUE. Used when rendering output filenames so the reported paths match what `boilerplate template` would produce. May be specified more than once.",
85+
},
86+
&cli.StringSliceFlag{
87+
Name: options.OptVarFile,
88+
Usage: "Load variable values from the YAML file `FILE`. May be specified more than once.",
89+
},
90+
&cli.BoolFlag{
91+
Name: options.OptIncludeBundle,
92+
Usage: "Include a `bundle` field in the JSON output containing the contents of every text file in the resolved dependency tree. The bundle is suitable for feeding back into the WASM `boilerplateInputsMap` and `boilerplateRenderFile` functions for warm-dispatch rendering.",
93+
},
94+
},
95+
},
96+
},
97+
}
98+
}
99+
100+
// runInputsMap routes output through c.App so tests can inject stdout/stderr.
101+
func runInputsMap(c *cli.Context) error {
102+
stdout := io.Writer(os.Stdout)
103+
if c.App != nil && c.App.Writer != nil {
104+
stdout = c.App.Writer
105+
}
106+
107+
stderr := io.Writer(os.Stderr)
108+
if c.App != nil && c.App.ErrWriter != nil {
109+
stderr = c.App.ErrWriter
110+
}
111+
112+
return runInputsMapTo(c, stdout, stderr)
113+
}
114+
115+
func runInputsMapTo(c *cli.Context, stdout, stderr io.Writer) error {
116+
vars, err := variables.ParseVars(c.StringSlice(options.OptVar), c.StringSlice(options.OptVarFile))
117+
if err != nil {
118+
writeJSONError(stdout, inputs.KindParseArgs, err)
119+
return cli.Exit("", 1)
120+
}
121+
122+
templateURL, templateFolder, err := getterhelper.DetermineTemplateConfig(c.String(options.OptTemplateURL))
123+
if err != nil {
124+
writeJSONError(stdout, inputs.KindParseArgs, err)
125+
return cli.Exit("", 1)
126+
}
127+
128+
opts := &options.BoilerplateOptions{
129+
Vars: vars,
130+
TemplateURL: templateURL,
131+
TemplateFolder: templateFolder,
132+
NonInteractive: true,
133+
NoHooks: true,
134+
NoShell: true,
135+
OnMissingKey: options.ZeroValue,
136+
OnMissingConfig: options.Exit,
137+
}
138+
139+
logger := logging.New(stderr, logging.LevelWarn)
140+
141+
ctx := context.Background()
142+
143+
result, err := inputs.FromOptions(ctx, logger, opts)
144+
if err != nil {
145+
writeJSONError(stdout, inputs.KindParse, err)
146+
return cli.Exit("", 1)
147+
}
148+
149+
var bundle *inputs.Bundle
150+
151+
if c.Bool(options.OptIncludeBundle) {
152+
b, notes, bundleErr := inputs.BundleFromOptions(ctx, logger, opts)
153+
if bundleErr != nil {
154+
writeJSONError(stdout, inputs.KindParse, bundleErr)
155+
return cli.Exit("", 1)
156+
}
157+
158+
bundle = b
159+
160+
for _, n := range notes {
161+
result.Errors = append(result.Errors, inputs.AnalysisError{
162+
Kind: n.Kind,
163+
Name: n.Name,
164+
Message: n.Message,
165+
})
166+
}
167+
}
168+
169+
enc := json.NewEncoder(stdout)
170+
enc.SetIndent("", " ")
171+
172+
if bundle != nil {
173+
envelope := struct {
174+
*inputs.Result
175+
Bundle *inputs.Bundle `json:"bundle"`
176+
}{
177+
Result: result,
178+
Bundle: bundle,
179+
}
180+
181+
if encErr := enc.Encode(envelope); encErr != nil {
182+
return fmt.Errorf("encode result: %w", encErr)
183+
}
184+
185+
return nil
186+
}
187+
188+
if encErr := enc.Encode(result); encErr != nil {
189+
return fmt.Errorf("encode result: %w", encErr)
190+
}
191+
192+
return nil
193+
}
194+
195+
// writeJSONError writes a small JSON document with a top-level "errors" array
196+
// containing a single entry matching the AnalysisError shape. Used when the
197+
// command cannot produce a meaningful Result (e.g., the root config did not
198+
// parse).
199+
func writeJSONError(w io.Writer, kind string, err error) {
200+
doc := struct {
201+
Errors []inputs.AnalysisError `json:"errors"`
202+
}{
203+
Errors: []inputs.AnalysisError{
204+
{Kind: kind, Message: err.Error()},
205+
},
206+
}
207+
208+
enc := json.NewEncoder(w)
209+
enc.SetIndent("", " ")
210+
211+
if encErr := enc.Encode(doc); encErr == nil {
212+
return
213+
}
214+
215+
// Fallback: build the document a second time using json.Marshal so the
216+
// message is encoded with the same JSON-string-escaping rules — `%q`
217+
// produces Go syntax, which is not byte-for-byte JSON-safe (e.g., it
218+
// emits \xNN for non-ASCII bytes, which JSON parsers reject).
219+
fallback, marshalErr := json.Marshal(struct {
220+
Errors []inputs.AnalysisError `json:"errors"`
221+
}{
222+
Errors: []inputs.AnalysisError{
223+
{Kind: "encode", Message: err.Error()},
224+
},
225+
})
226+
if marshalErr != nil {
227+
return
228+
}
229+
230+
_, _ = w.Write(fallback)
231+
_, _ = w.Write([]byte("\n"))
232+
}

cmd/wasm/full/main.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//go:build js && wasm
2+
3+
// The full WASM build registers boilerplateRenderTemplate,
4+
// boilerplateInputsMap, boilerplateRenderFile, boilerplateRenderFiles,
5+
// and the prepared-bundle trio (boilerplatePrepareBundle,
6+
// boilerplateRenderFilesWithHandle, boilerplateReleaseBundle). It
7+
// pulls in the config package and its dependencies, so the binary is
8+
// substantially larger than the lite build.
9+
package main
10+
11+
import (
12+
"syscall/js"
13+
14+
"github.com/gruntwork-io/boilerplate/cmd/wasm/inputs"
15+
"github.com/gruntwork-io/boilerplate/cmd/wasm/preparedbundle"
16+
"github.com/gruntwork-io/boilerplate/cmd/wasm/render"
17+
"github.com/gruntwork-io/boilerplate/cmd/wasm/renderfile"
18+
"github.com/gruntwork-io/boilerplate/cmd/wasm/renderfiles"
19+
)
20+
21+
func main() {
22+
js.Global().Set("boilerplateRenderTemplate", render.Handler())
23+
js.Global().Set("boilerplateInputsMap", inputs.Handler())
24+
js.Global().Set("boilerplateRenderFile", renderfile.Handler())
25+
js.Global().Set("boilerplateRenderFiles", renderfiles.Handler())
26+
27+
// The prepared-bundle handlers share a single handle store so a
28+
// handle returned by Prepare resolves correctly inside the matching
29+
// RenderFilesWithHandle / Release call. Wired up exactly once at
30+
// main() time.
31+
pb := preparedbundle.New()
32+
33+
js.Global().Set("boilerplatePrepareBundle", pb.Prepare)
34+
js.Global().Set("boilerplateRenderFilesWithHandle", pb.RenderFilesWithHandle)
35+
js.Global().Set("boilerplateReleaseBundle", pb.Release)
36+
37+
// Block forever to keep Go runtime alive.
38+
select {}
39+
}

0 commit comments

Comments
 (0)