The first path up the mountain · 0.1.0-preview.8
Walk the path: build a component, page, and site.
Begin with visible HTML. Add typed data, nest small templates into a complete document, and render it from an ordinary net/http handler. At the summit, the browser receives a dynamic page and the production program depends only on Go and the small sando runtime.
You will make this small application:
hello-hime/
├── cmd/site/main.go
├── internal/views/
│ ├── badge.sando
│ ├── home.sando
│ ├── layout.sando
│ └── views.go
├── go.mod
└── go.work # local preview bridge
Live proof
Compiled before deployment. Rendered for this request.
This tutorial's .sando source was compiled into ordinary Go before the site was deployed. The running Go application has just called sando.Render with fresh typed data to build this response.
This panel is itself a typed .sando component nested into the tutorial with <?~ … ?>.
- Rendered at
- Process-local tutorial render
- #19
- Cache policy
Cache-Control: no-store- Measured render
Server-Timing: sandoresponse header
Refresh this page and the time and render number change. The counter resets when the service restarts. The timing header measures this response's buffered component render; it is evidence, not a universal benchmark.
Step 1
Prepare the current source preview
You need Go 1.25 or newer and a Git client. Clone Sandwich Hime beside the application, build the development-time compiler, and initialize the application module:
mkdir sandwich-hime-walk
cd sandwich-hime-walk
git clone https://gitea.speelman.ca/gamertan/sandwich-hime.git
cd sandwich-hime
go install ./cmd/himesan
cd ..
mkdir hello-hime
cd hello-hime
go mod init example.com/hello-hime
go mod edit -require=gamertan.com/sandwich-hime/sando@v0.0.0
go work init .
go work edit -replace=gamertan.com/sandwich-hime/sando=../sandwich-hime/sando
mkdir -p cmd/site internal/views
go install ./cmd/himesan installs the compiler from the source you just cloned. Make sure Go's install directory—normally $(go env GOPATH)/bin—is on your PATH.
The v0.0.0 requirement and go.work replacement are an explicit local bridge, not a published release. Keep go.work and go.work.sum out of a reusable example or application commit. The application module still records that its generated code needs the sando runtime; your workspace tells Go where the preview runtime source lives.
Step 2
Define the data and the composition seam
Create internal/views/views.go. These ordinary Go types are the entire data contract between the handler and the templates:
package views
import "gamertan.com/sandwich-hime/sando"
type Link struct {
Label string
URL string
}
type HomeView struct {
Visitor string
Links []Link
}
type LayoutView struct {
Title string
Body sando.Component
}
HomeView carries ordinary page data. LayoutView.Body accepts any renderable component, so the outer document can wrap a home page today and another typed page tomorrow. There is no template-name registry or implicit request global.
Step 3
Build one small component
Create internal/views/badge.sando:
<?sando go
package views
func Badge(label string)
?>
<span class="badge"><?= label ?></span>
The header declares one typed template constructor. The body is direct HTML. <?= label ?> writes untrusted data using the escaping rule for HTML text. EOF closes the template.
Generation will create badge.sando.go beside it with an ordinary API shaped like:
func Badge(label string) sando.Component
Do not hand-edit the generated neighbor. Commit it after generation; Hime-san owns subsequent deterministic replacements of that marked file.
Step 4
Compose the component into a page
Create internal/views/home.sando. The Go islands are deliberately small: one nested component call and one loop.
<?sando go
package views
func Home(view HomeView)
?>
<main id="main">
<h1>Hello, <?= view.Visitor ?>.</h1>
<p><?~ Badge("rendered on request") ?></p>
<h2>Choose a trail</h2>
<ul>
<? for _, link := range view.Links { ?>
<li><a href="<?= link.URL ?>"><?= link.Label ?></a></li>
<? } ?>
</ul>
</main>
<?~ … ?> nests a component only at an HTML content boundary and propagates its render error. The loop is ordinary trusted Go source. Each label is escaped as text; each URL is checked and written using the recognized URL-attribute rules.
Now create internal/views/layout.sando for the complete document:
<?sando go
package views
func Layout(view LayoutView)
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= view.Title ?></title>
</head>
<body>
<a href="#main">Skip to content</a>
<?~ view.Body ?>
</body>
</html>
A page and a small partial use the same component contract. Nesting does not require an inheritance language: Layout(Home(...)) is typed Go composition.
Step 5
Render it from an ordinary Go server
Create cmd/site/main.go:
package main
import (
"bytes"
"log"
"net/http"
"strings"
"example.com/hello-hime/internal/views"
"gamertan.com/sandwich-hime/sando"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", home)
log.Println("listening on http://127.0.0.1:8080")
log.Fatal(http.ListenAndServe("127.0.0.1:8080", mux))
}
func home(w http.ResponseWriter, r *http.Request) {
visitor := strings.TrimSpace(r.URL.Query().Get("name"))
if visitor == "" {
visitor = "traveler"
}
body := views.Home(views.HomeView{
Visitor: visitor,
Links: []views.Link{
{Label: "Start", URL: "/"},
{Label: "Sandwich Hime", URL: "https://sandwichhime.com/"},
},
})
page := views.Layout(views.LayoutView{
Title: "Hello from the mountain",
Body: body,
})
var output bytes.Buffer
if err := sando.Render(r.Context(), &output, page); err != nil {
log.Printf("render home: %v", err)
http.Error(w, "could not render page", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(output.Bytes())
}
The router, status, headers, cache policy, logging, buffering, and listener remain application decisions. Sandwich Hime supplies components and rendering. Buffering means a render error can still become a clean 500 before response bytes are committed.
The ?name= query makes the response genuinely dynamic. It is intentionally treated as untrusted data and escaped by the generated component on every request.
himesan generate runs during development or CI when template source changes; it does not run on a request. The compiled application calls sando.Render on each request with that request's typed data. Nested <?~ … ?> composition has already become ordinary generated Go by then.Step 6
Generate, check, test, and build
Run the compiler against the template directory, then use the normal Go toolchain:
himesan generate internal/views
himesan check internal/views
go mod tidy
go test ./...
go build ./cmd/site
go run ./cmd/site
Open http://127.0.0.1:8080/?name=Hime-san. Then try an adversarial value such as ?name=<script>alert(1)</script>: those characters appear as text, not executable markup.
generate writes the adjacent .sando.go files. check writes nothing and exits nonzero when a template is invalid or generated output is missing or stale. The later go test and go build steps type-check the Go names and expressions in the generated package.
Step 7
Turn the security contract into a test
Create internal/views/views_test.go:
package views
import (
"context"
"strings"
"testing"
"gamertan.com/sandwich-hime/sando"
)
func TestHomeEscapesUntrustedText(t *testing.T) {
var output strings.Builder
page := Home(HomeView{Visitor: `<script>alert("no")</script>`})
if err := sando.Render(context.Background(), &output, page); err != nil {
t.Fatal(err)
}
if strings.Contains(output.String(), "<script>") {
t.Fatalf("visitor became markup: %s", output.String())
}
}
func TestHomeRejectsDangerousURL(t *testing.T) {
var output strings.Builder
page := Home(HomeView{Links: []Link{
{Label: "unsafe", URL: "javascript:alert(1)"},
}})
if err := sando.Render(context.Background(), &output, page); err == nil {
t.Fatal("dangerous URL rendered without an error")
}
}
Run go test ./... again. Templates and embedded Go are trusted source code; values are untrusted. Sandwich Hime is a contextual compiler, not a sandbox. Unsupported or ambiguous contexts fail generation, dangerous ordinary URL schemes fail rendering, and explicit Trust* values are capabilities that deserve conspicuous review.
Continue with the full security model before accepting user-controlled content in a real application.
Step 8
Know what development uses—and what production ships
*.sando- Human-authored template source. Keep it.
*.sando.go- Deterministic, reviewable generated Go. Commit it.
himesan- The AGPL-3.0-only development compiler and optional local supervisor. It does not enter the production dependency graph.
sando- The small Apache-2.0 runtime used by generated components and the production application.
- Your application
- The router, middleware, HTTP policy, data access, tests, process, and deployment remain yours.
You can inspect the boundary directly:
go list -deps ./cmd/site | grep '^gamertan.com/sandwich-hime'
For this application, the result should contain gamertan.com/sandwich-hime/sando and not the compiler module root or cmd/himesan. The generated Go lets production use an ordinary go build without executing the generator or project templates.
You have walked the complete path: typed data → visible templates → nested components → generated Go → an ordinary dynamic server. From here, add another page constructor and route, extract another partial when repetition earns it, and keep the HTML in sight.
Want the finished trail beside the lesson? The 0BSD tutorial starter is a runnable companion with the same boundaries and a slightly richer dynamic trail model. Use it, change it, or don't—we're just glad you're here with us.