commit ddd91c7e7c2eb68712669820feab9a4ea4fa3aa4 Author: Cole Speelman Date: Tue Aug 11 23:59:44 2026 -0400 feat: publish the Sandwich Hime tutorial starter diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b64639c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: 0BSD + +* text=auto eol=lf +*.sando.go linguist-generated=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e0d15d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: 0BSD + +# The current preview runtime is connected through a local workspace only. +go.work +go.work.sum + +# Local build output. +/site +/sandwich-hime-tutorial diff --git a/COPYRIGHT b/COPYRIGHT new file mode 100644 index 0000000..27ed889 --- /dev/null +++ b/COPYRIGHT @@ -0,0 +1,2 @@ +SPDX-License-Identifier: 0BSD +SPDX-FileCopyrightText: 2026 Cole Speelman diff --git a/GENERATED_CODE.md b/GENERATED_CODE.md new file mode 100644 index 0000000..ab66b5a --- /dev/null +++ b/GENERATED_CODE.md @@ -0,0 +1,12 @@ + + +# Generated code + +Every `*.sando.go` file is an owned output of the neighboring `*.sando` source. +Commit both files so production builds need only ordinary Go and the small +`sando` runtime. + +Never hand-edit a generated neighbor. Run `himesan generate internal/views`, +review the deterministic diff, and use `himesan check internal/views` in local +verification or CI. The generator records its version, runtime ABI, source +digest, and source mappings in each output. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d7a6149 --- /dev/null +++ b/LICENSE @@ -0,0 +1,14 @@ +Zero-Clause BSD + +Copyright (C) 2026 Cole Speelman + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/LICENSES.md b/LICENSES.md new file mode 100644 index 0000000..643f02a --- /dev/null +++ b/LICENSES.md @@ -0,0 +1,16 @@ + + +# License map + +This starter is intentionally easy to copy. + +- Human-authored source, templates, tests, scripts, and documentation in this + repository are offered under the [Zero-Clause BSD license](LICENSE). +- Adjacent `*.sando.go` files are generated application code. In this + repository they follow the same 0BSD choice as their source templates, under + Sandwich Hime's generated-output policy and output exception. Do not add or + repair headers by hand; regenerate them with Hime-san. +- The separately obtained Hime-san compiler is AGPL-3.0-only. +- The separately obtained `sando` runtime is Apache-2.0. + +The compiler and runtime are dependencies, not copied into this repository. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec2509e --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ + + +# Sandwich Hime tutorial starter + +This is the runnable companion to the official +[Walk the path tutorial](https://sandwichhime.com/docs/tutorial/). The website +owns the lesson; this repository is the small application you can clone, run, +take apart, and turn into something of your own. It takes the lesson's minimal +`Link` model one small step further with a described `Trail` and adds visible +per-request proof, while preserving the same component structure. + +**Use this, change it, or don’t—we’re just glad you’re here with us.** + +The starter demonstrates the boundary plainly: + +- Hime-san compiles three typed `.sando` templates into committed Go during + development. +- `Badge`, `Home`, and `Layout` nest through the same `sando.Component` + contract. +- An ordinary `net/http` handler builds the trail list and typed view data on + every request. +- `?name=` is untrusted input and is contextually escaped by generated code. +- Each successful response contains a fresh UTC timestamp and process-local + request number, uses `Cache-Control: no-store`, and reports buffered render + time through `Server-Timing`. +- Production imports the Apache-2.0 `sando` runtime, not the compiler. + +## Run the current source preview + +Sandwich Hime does not have immutable public release tags yet. Do not invent a +version-shaped install command: clone the compiler and this starter side by +side, then use a local Go workspace as an explicit preview bridge. + +```sh +mkdir sandwich-hime-walk +cd sandwich-hime-walk + +git clone https://gitea.speelman.ca/gamertan/sandwich-hime.git +git clone https://gitea.speelman.ca/gamertan/sandwich-hime-tutorial.git + +cd sandwich-hime +go install ./cmd/himesan + +cd ../sandwich-hime-tutorial +go work init . +go work edit -replace=gamertan.com/sandwich-hime/sando=../sandwich-hime/sando + +./scripts/verify.sh +go run ./cmd/site +``` + +Make sure `$(go env GOPATH)/bin` is on `PATH`, or set `HIMESAN_BIN` to the +compiler executable when running the verification script. `go.work` and +`go.work.sum` are intentionally ignored: they are local preview wiring, not a +claim that `v0.0.0` was published. + +Open [http://127.0.0.1:8080/?name=Hime-san](http://127.0.0.1:8080/?name=Hime-san), +refresh it, and watch the request number and UTC time change. Then try: + +```text +http://127.0.0.1:8080/?name= +``` + +The browser displays those characters as text. They do not become markup or +script. The tests also prove that an ordinary `javascript:` URL is rejected at +render time. + +## Project map + +```text +cmd/site/main.go application-owned listener +internal/server/handler.go router, typed request data, and HTTP policy +internal/views/views.go typed template contracts +internal/views/*.sando templates people edit +internal/views/*.sando.go committed generated Go +scripts/verify.sh generation, tests, build, and dependency gate +``` + +The application owns the server, routing, headers, data, and deployment. +Sandwich Hime owns template compilation; `sando` owns the tiny runtime render +contract. Read the [language and security documentation](https://sandwichhime.com/docs/) +before accepting real user content. + +## What the verification gate proves + +`./scripts/verify.sh` checks committed output, generates twice and compares +digests, runs all tests and `go vet`, builds the server into a temporary +directory, and inspects its Go dependency graph. The only production +Sandwich Hime package allowed by that graph is +`gamertan.com/sandwich-hime/sando`. + +The human-authored starter is [0BSD](LICENSES.md), specifically so copying it +does not drag a complicated license conversation into your application. diff --git a/cmd/site/main.go b/cmd/site/main.go new file mode 100644 index 0000000..e54d7e4 --- /dev/null +++ b/cmd/site/main.go @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: 0BSD + +package main + +import ( + "log" + "net/http" + + "gitea.speelman.ca/gamertan/sandwich-hime-tutorial/internal/server" +) + +func main() { + const address = "127.0.0.1:8080" + log.Printf("listening on http://%s", address) + log.Fatal(http.ListenAndServe(address, server.New())) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..21f615a --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: 0BSD + +module gitea.speelman.ca/gamertan/sandwich-hime-tutorial + +go 1.25 + +require gamertan.com/sandwich-hime/sando v0.0.0 diff --git a/internal/server/handler.go b/internal/server/handler.go new file mode 100644 index 0000000..cc23dfd --- /dev/null +++ b/internal/server/handler.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: 0BSD + +// Package server owns the example application's HTTP policy and request data. +package server + +import ( + "bytes" + "fmt" + "net/http" + "strconv" + "strings" + "sync/atomic" + "time" + "unicode/utf8" + + "gamertan.com/sandwich-hime/sando" + "gitea.speelman.ca/gamertan/sandwich-hime-tutorial/internal/views" +) + +const maxVisitorRunes = 80 + +// New returns the complete example application using the system clock. +func New() http.Handler { + return NewWithClock(time.Now) +} + +// NewWithClock returns the application with an injectable request clock. +// It is exported from this internal package so tests can make time exact. +func NewWithClock(now func() time.Time) http.Handler { + if now == nil { + now = time.Now + } + + application := &application{now: now} + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", application.home) + return mux +} + +type application struct { + now func() time.Time + requests atomic.Uint64 +} + +func (a *application) home(w http.ResponseWriter, r *http.Request) { + renderedAt := a.now().UTC() + requestNumber := a.requests.Add(1) + + body := views.Home(views.HomeView{ + Visitor: normalizeVisitor(r.URL.Query().Get("name")), + Trails: trailsForRequest(), + }) + page := views.Layout(views.LayoutView{ + Title: "A small Sandwich Hime site", + Body: body, + RenderedAtUTC: renderedAt.Format(time.RFC3339Nano), + RequestNumber: requestNumber, + }) + + var output bytes.Buffer + renderStarted := time.Now() + if err := sando.Render(r.Context(), &output, page); err != nil { + w.Header().Set("Cache-Control", "no-store") + http.Error(w, "could not render page", http.StatusInternalServerError) + return + } + renderDuration := time.Since(renderStarted) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Length", strconv.Itoa(output.Len())) + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Server-Timing", fmt.Sprintf(`sando;dur=%.3f;desc="buffered component render"`, float64(renderDuration)/float64(time.Millisecond))) + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodHead { + return + } + _, _ = w.Write(output.Bytes()) +} + +func trailsForRequest() []views.Trail { + return []views.Trail{ + { + Label: "Read the official tutorial", + Description: "Walk through the source one typed component at a time.", + URL: "https://sandwichhime.com/docs/tutorial/", + }, + { + Label: "Study the language and safety boundary", + Description: "See where contextual escaping succeeds and where compilation deliberately stops.", + URL: "https://sandwichhime.com/docs/", + }, + { + Label: "Render another visitor", + Description: "This local link sends different untrusted request data through the same compiled templates.", + URL: "/?name=friend", + }, + } +} + +func normalizeVisitor(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "traveler" + } + if utf8.RuneCountInString(value) <= maxVisitorRunes { + return value + } + runes := []rune(value) + return string(runes[:maxVisitorRunes]) +} diff --git a/internal/server/handler_test.go b/internal/server/handler_test.go new file mode 100644 index 0000000..af53b16 --- /dev/null +++ b/internal/server/handler_test.go @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: 0BSD + +package server + +import ( + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" +) + +func TestHomeIsDynamicEscapedAndNotCached(t *testing.T) { + base := time.Date(2026, time.August, 11, 14, 30, 0, 0, time.UTC) + clockCalls := 0 + handler := NewWithClock(func() time.Time { + result := base.Add(time.Duration(clockCalls) * time.Second) + clockCalls++ + return result + }) + + first := request(t, handler, http.MethodGet, `/?name=%3Cscript%3Ealert%281%29%3C%2Fscript%3E`) + second := request(t, handler, http.MethodGet, "/?name=friend") + + if first.Code != http.StatusOK || second.Code != http.StatusOK { + t.Fatalf("unexpected statuses: first=%d second=%d", first.Code, second.Code) + } + if first.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("Cache-Control = %q", first.Header().Get("Cache-Control")) + } + if timing := first.Header().Get("Server-Timing"); !strings.HasPrefix(timing, "sando;dur=") || !strings.Contains(timing, "buffered component render") { + t.Fatalf("Server-Timing = %q", timing) + } + if strings.Contains(first.Body.String(), "` + var output strings.Builder + page := Home(HomeView{Visitor: attack}) + if err := sando.Render(context.Background(), &output, page); err != nil { + t.Fatal(err) + } + + rendered := output.String() + if strings.Contains(rendered, attack) || strings.Contains(rendered, "