feat: publish the Sandwich Hime tutorial starter

This commit is contained in:
2026-08-11 23:59:44 -04:00
commit ddd91c7e7c
20 changed files with 790 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# SPDX-License-Identifier: 0BSD
* text=auto eol=lf
*.sando.go linguist-generated=true
+9
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
SPDX-License-Identifier: 0BSD
SPDX-FileCopyrightText: 2026 Cole Speelman
+12
View File
@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: 0BSD -->
# 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.
+14
View File
@@ -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.
+16
View File
@@ -0,0 +1,16 @@
<!-- SPDX-License-Identifier: 0BSD -->
# 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.
+93
View File
@@ -0,0 +1,93 @@
<!-- SPDX-License-Identifier: 0BSD -->
# 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 dont—were just glad youre 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=<script>alert("no")</script>
```
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.
+16
View File
@@ -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()))
}
+7
View File
@@ -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
+113
View File
@@ -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])
}
+103
View File
@@ -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(), "<script>") || !strings.Contains(first.Body.String(), "&lt;script&gt;") {
t.Fatalf("visitor was not safely escaped: %s", first.Body.String())
}
if !strings.Contains(first.Body.String(), "2026-08-11T14:30:00Z") || !strings.Contains(first.Body.String(), "#1") {
t.Fatalf("first response is missing its dynamic proof: %s", first.Body.String())
}
if !strings.Contains(second.Body.String(), "2026-08-11T14:30:01Z") || !strings.Contains(second.Body.String(), "#2") {
t.Fatalf("second response is missing fresh dynamic proof: %s", second.Body.String())
}
if first.Body.String() == second.Body.String() {
t.Fatal("separate requests produced identical bodies")
}
}
func TestHeadHasGetHeadersAndNoBody(t *testing.T) {
handler := NewWithClock(func() time.Time {
return time.Date(2026, time.August, 11, 14, 30, 0, 0, time.UTC)
})
response := request(t, handler, http.MethodHead, "/")
if response.Code != http.StatusOK {
t.Fatalf("status = %d", response.Code)
}
if response.Body.Len() != 0 {
t.Fatalf("HEAD wrote %d body bytes", response.Body.Len())
}
length, err := strconv.Atoi(response.Header().Get("Content-Length"))
if err != nil || length <= 0 {
t.Fatalf("Content-Length = %q, err = %v", response.Header().Get("Content-Length"), err)
}
if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("Server-Timing") == "" {
t.Fatalf("HEAD omitted dynamic response headers: %v", response.Header())
}
}
func TestRouterOwnsMethodAndPathPolicy(t *testing.T) {
handler := New()
if response := request(t, handler, http.MethodPost, "/"); response.Code != http.StatusMethodNotAllowed {
t.Fatalf("POST / status = %d", response.Code)
}
if response := request(t, handler, http.MethodGet, "/missing"); response.Code != http.StatusNotFound {
t.Fatalf("GET /missing status = %d", response.Code)
}
}
func request(t *testing.T, handler http.Handler, method, target string) *httptest.ResponseRecorder {
t.Helper()
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(method, target, nil))
return recorder
}
func TestGetContentLengthMatchesBufferedBody(t *testing.T) {
handler := New()
response := request(t, handler, http.MethodGet, "/")
want, err := strconv.Atoi(response.Header().Get("Content-Length"))
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(response.Result().Body)
if err != nil {
t.Fatal(err)
}
if len(body) != want {
t.Fatalf("body length = %d, Content-Length = %d", len(body), want)
}
}
+7
View File
@@ -0,0 +1,7 @@
<?sando go
package views
func Badge(label string)
?>
<?# SPDX-License-Identifier: 0BSD ?>
<span class="badge"><?= label ?></span>
+37
View File
@@ -0,0 +1,37 @@
// Code generated by himesan; DO NOT EDIT.
// himesan:compiler 0.1.0-dev
// himesan:runtime-abi sando.v1
// himesan:source-sha256 241093d3b845d20c38e5a0fe5cf2213d6bb9114138818dbe98d8b3a99ff19d24
package views
import (
__himesan_context "context"
__himesan_sando "gamertan.com/sandwich-hime/sando"
__himesan_io "io"
)
var _ = __himesan_sando.ABI
func Badge(label string) __himesan_sando.Component {
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
_ = __himesan_render_context
//line internal/views/badge.sando:5:3
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
return __himesan_error
}
//line internal/views/badge.sando:6:37
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<span class=\"badge\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/views/badge.sando:7:25
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (label)); __himesan_error != nil {
return __himesan_error
}
//line internal/views/badge.sando:7:33
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</span>\n"); __himesan_error != nil {
return __himesan_error
}
return nil
})
}
+27
View File
@@ -0,0 +1,27 @@
<?sando go
package views
func Home(view HomeView)
?>
<?# SPDX-License-Identifier: 0BSD ?>
<main id="main" class="page">
<p class="eyebrow"><?~ Badge("rendered on this request") ?></p>
<h1>Hello, <?= view.Visitor ?>.</h1>
<p class="lede">This page began as readable HTML, became typed Go, and received its data from an ordinary server handler just now.</p>
<section aria-labelledby="trail-heading">
<h2 id="trail-heading">Choose a trail</h2>
<div class="trail-grid">
<? for _, trail := range view.Trails { ?>
<article class="trail">
<h3><a href="<?= trail.URL ?>"><?= trail.Label ?></a></h3>
<p><?= trail.Description ?></p>
</article>
<? } ?>
</div>
</section>
<aside class="note" aria-label="Template boundary">
<strong>Two moments, one calm path:</strong> Hime-san generated the component code when the template changed. The compiled application rendered this response with your request data now.
</aside>
</main>
+81
View File
@@ -0,0 +1,81 @@
// Code generated by himesan; DO NOT EDIT.
// himesan:compiler 0.1.0-dev
// himesan:runtime-abi sando.v1
// himesan:source-sha256 2f04e0f2e6caae73ea3d419a88c53afcaf25d300e76dc522ae2f418a03a68def
package views
import (
__himesan_context "context"
__himesan_sando "gamertan.com/sandwich-hime/sando"
__himesan_io "io"
)
var _ = __himesan_sando.ABI
func Home(view HomeView) __himesan_sando.Component {
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
_ = __himesan_render_context
//line internal/views/home.sando:5:3
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
return __himesan_error
}
//line internal/views/home.sando:6:37
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<main id=\"main\" class=\"page\">\n <p class=\"eyebrow\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/views/home.sando:8:26
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (Badge("rendered on this request"))); __himesan_error != nil {
return __himesan_error
}
//line internal/views/home.sando:8:62
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</p>\n <h1>Hello, "); __himesan_error != nil {
return __himesan_error
}
//line internal/views/home.sando:9:18
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.Visitor)); __himesan_error != nil {
return __himesan_error
}
//line internal/views/home.sando:9:33
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, ".</h1>\n <p class=\"lede\">This page began as readable HTML, became typed Go, and received its data from an ordinary server handler just now.</p>\n\n <section aria-labelledby=\"trail-heading\">\n <h2 id=\"trail-heading\">Choose a trail</h2>\n <div class=\"trail-grid\">\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/views/home.sando:15:10
for _, trail := range view.Trails {
//line internal/views/home.sando:15:48
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <article class=\"trail\">\n <h3><a href=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/views/home.sando:17:28
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (trail.URL)); __himesan_error != nil {
return __himesan_error
}
//line internal/views/home.sando:17:40
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/views/home.sando:17:46
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (trail.Label)); __himesan_error != nil {
return __himesan_error
}
//line internal/views/home.sando:17:60
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</a></h3>\n <p>"); __himesan_error != nil {
return __himesan_error
}
//line internal/views/home.sando:18:18
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (trail.Description)); __himesan_error != nil {
return __himesan_error
}
//line internal/views/home.sando:18:38
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</p>\n </article>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/views/home.sando:20:10
}
//line internal/views/home.sando:20:14
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </div>\n </section>\n\n <aside class=\"note\" aria-label=\"Template boundary\">\n <strong>Two moments, one calm path:</strong> Hime-san generated the component code when the template changed. The compiled application rendered this response with your request data now.\n </aside>\n</main>\n"); __himesan_error != nil {
return __himesan_error
}
return nil
})
}
+53
View File
@@ -0,0 +1,53 @@
<?sando go
package views
func Layout(view LayoutView)
?>
<?# SPDX-License-Identifier: 0BSD ?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="A tiny dynamic Go website built with Sandwich Hime.">
<title><?= view.Title ?></title>
<style>
:root { color-scheme: light dark; font-family: ui-rounded, system-ui, sans-serif; line-height: 1.6; }
* { box-sizing: border-box; }
body { margin: 0; background: #f6f1e7; color: #28231d; }
a { color: #74449a; text-underline-offset: .2em; }
a:focus-visible { outline: .2rem solid #c85278; outline-offset: .2rem; }
.skip { position: absolute; left: .75rem; top: -5rem; padding: .6rem .8rem; background: #fff; color: #28231d; z-index: 2; }
.skip:focus { top: .75rem; }
.page, footer { width: min(68rem, calc(100% - 2rem)); margin-inline: auto; }
.page { padding-block: clamp(3rem, 9vw, 7rem); }
.eyebrow { margin: 0 0 1rem; }
.badge { display: inline-block; padding: .25rem .65rem; border: 1px solid currentColor; border-radius: 999px; font-size: .84rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
h1 { max-width: 16ch; margin: 0; font-size: clamp(2.5rem, 9vw, 5.8rem); line-height: .98; overflow-wrap: anywhere; }
h2 { margin-top: 3.5rem; font-size: clamp(1.6rem, 4vw, 2.4rem); }
h3 { margin-top: 0; }
.lede { max-width: 46rem; font-size: clamp(1.1rem, 2vw, 1.35rem); }
.trail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr)); gap: 1rem; }
.trail, .note { padding: 1.1rem; border: 1px solid #cdbfae; border-radius: .8rem; background: #fffaf1; }
.trail p { margin-bottom: 0; }
.note { max-width: 52rem; margin-top: 2rem; }
footer { padding-block: 1.5rem 2.5rem; border-top: 1px solid #cdbfae; font-size: .9rem; }
footer p { margin: .25rem 0; }
code { overflow-wrap: anywhere; }
@media (prefers-color-scheme: dark) {
body { background: #1d1a20; color: #f5eee5; }
a { color: #d9a5ff; }
.trail, .note { border-color: #5b505f; background: #29242d; }
footer { border-color: #5b505f; }
}
</style>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<?~ view.Body ?>
<footer>
<p>Rendered at <time datetime="<?= view.RenderedAtUTC ?>"><?= view.RenderedAtUTC ?></time> as request <strong>#<?= view.RequestNumber ?></strong> since this process started.</p>
<p>This HTML response is not cached. Refresh it and the server builds fresh typed data.</p>
</footer>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
// Code generated by himesan; DO NOT EDIT.
// himesan:compiler 0.1.0-dev
// himesan:runtime-abi sando.v1
// himesan:source-sha256 e56b373194349ae32dcbad4a26c1d1a3d133e335ff916cef064e12ca2901adf3
package views
import (
__himesan_context "context"
__himesan_sando "gamertan.com/sandwich-hime/sando"
__himesan_io "io"
)
var _ = __himesan_sando.ABI
func Layout(view LayoutView) __himesan_sando.Component {
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
_ = __himesan_render_context
//line internal/views/layout.sando:5:3
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
return __himesan_error
}
//line internal/views/layout.sando:6:37
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <meta name=\"description\" content=\"A tiny dynamic Go website built with Sandwich Hime.\">\n <title>"); __himesan_error != nil {
return __himesan_error
}
//line internal/views/layout.sando:13:14
if __himesan_error := __himesan_sando.WriteRCDATA(__himesan_writer, (view.Title)); __himesan_error != nil {
return __himesan_error
}
//line internal/views/layout.sando:13:27
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</title>\n <style>\n :root { color-scheme: light dark; font-family: ui-rounded, system-ui, sans-serif; line-height: 1.6; }\n * { box-sizing: border-box; }\n body { margin: 0; background: #f6f1e7; color: #28231d; }\n a { color: #74449a; text-underline-offset: .2em; }\n a:focus-visible { outline: .2rem solid #c85278; outline-offset: .2rem; }\n .skip { position: absolute; left: .75rem; top: -5rem; padding: .6rem .8rem; background: #fff; color: #28231d; z-index: 2; }\n .skip:focus { top: .75rem; }\n .page, footer { width: min(68rem, calc(100% - 2rem)); margin-inline: auto; }\n .page { padding-block: clamp(3rem, 9vw, 7rem); }\n .eyebrow { margin: 0 0 1rem; }\n .badge { display: inline-block; padding: .25rem .65rem; border: 1px solid currentColor; border-radius: 999px; font-size: .84rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }\n h1 { max-width: 16ch; margin: 0; font-size: clamp(2.5rem, 9vw, 5.8rem); line-height: .98; overflow-wrap: anywhere; }\n h2 { margin-top: 3.5rem; font-size: clamp(1.6rem, 4vw, 2.4rem); }\n h3 { margin-top: 0; }\n .lede { max-width: 46rem; font-size: clamp(1.1rem, 2vw, 1.35rem); }\n .trail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr)); gap: 1rem; }\n .trail, .note { padding: 1.1rem; border: 1px solid #cdbfae; border-radius: .8rem; background: #fffaf1; }\n .trail p { margin-bottom: 0; }\n .note { max-width: 52rem; margin-top: 2rem; }\n footer { padding-block: 1.5rem 2.5rem; border-top: 1px solid #cdbfae; font-size: .9rem; }\n footer p { margin: .25rem 0; }\n code { overflow-wrap: anywhere; }\n @media (prefers-color-scheme: dark) {\n body { background: #1d1a20; color: #f5eee5; }\n a { color: #d9a5ff; }\n .trail, .note { border-color: #5b505f; background: #29242d; }\n footer { border-color: #5b505f; }\n }\n </style>\n</head>\n<body>\n <a class=\"skip\" href=\"#main\">Skip to content</a>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/views/layout.sando:47:7
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (view.Body)); __himesan_error != nil {
return __himesan_error
}
//line internal/views/layout.sando:47:19
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <footer>\n <p>Rendered at <time datetime=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/views/layout.sando:49:40
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.RenderedAtUTC)); __himesan_error != nil {
return __himesan_error
}
//line internal/views/layout.sando:49:61
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/views/layout.sando:49:67
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.RenderedAtUTC)); __himesan_error != nil {
return __himesan_error
}
//line internal/views/layout.sando:49:88
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</time> as request <strong>#"); __himesan_error != nil {
return __himesan_error
}
//line internal/views/layout.sando:49:120
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.RequestNumber)); __himesan_error != nil {
return __himesan_error
}
//line internal/views/layout.sando:49:141
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</strong> since this process started.</p>\n <p>This HTML response is not cached. Refresh it and the server builds fresh typed data.</p>\n </footer>\n</body>\n</html>\n"); __himesan_error != nil {
return __himesan_error
}
return nil
})
}
+26
View File
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: 0BSD
package views
import "gamertan.com/sandwich-hime/sando"
// Trail is one server-provided destination rendered by Home.
type Trail struct {
Label string
Description string
URL string
}
// HomeView is the complete typed input to the inner page component.
type HomeView struct {
Visitor string
Trails []Trail
}
// LayoutView is the typed input to the full-document component.
type LayoutView struct {
Title string
Body sando.Component
RenderedAtUTC string
RequestNumber uint64
}
+45
View File
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: 0BSD
package views
import (
"context"
"strings"
"testing"
"gamertan.com/sandwich-hime/sando"
)
func TestHomeEscapesUntrustedTextAndNestsBadge(t *testing.T) {
t.Parallel()
const attack = `<script>alert("no")</script>`
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, "<script>") {
t.Fatalf("visitor became markup: %s", rendered)
}
if !strings.Contains(rendered, "&lt;script&gt;") {
t.Fatalf("escaped visitor is missing: %s", rendered)
}
if !strings.Contains(rendered, `<span class="badge">rendered on this request</span>`) {
t.Fatalf("nested Badge component is missing: %s", rendered)
}
}
func TestHomeRejectsDangerousURL(t *testing.T) {
t.Parallel()
var output strings.Builder
page := Home(HomeView{Trails: []Trail{
{Label: "unsafe", URL: "javascript:alert(1)"},
}})
if err := sando.Render(context.Background(), &output, page); err == nil {
t.Fatal("dangerous URL rendered without an error")
}
}
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: 0BSD
set -euo pipefail
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
cd "$repo_root"
himesan_bin=${HIMESAN_BIN:-himesan}
if ! command -v "$himesan_bin" >/dev/null 2>&1; then
echo "himesan was not found; install it from the neighboring Sandwich Hime checkout or set HIMESAN_BIN" >&2
exit 1
fi
if [[ ! -f go.work ]]; then
echo "go.work is missing; follow the README preview bridge commands first" >&2
exit 1
fi
generated_digest() {
find internal/views -type f -name '*.sando.go' -print0 \
| sort -z \
| xargs -0 sha256sum
}
"$himesan_bin" check internal/views
before=$(generated_digest)
"$himesan_bin" generate internal/views
after_first=$(generated_digest)
"$himesan_bin" generate internal/views
after_second=$(generated_digest)
"$himesan_bin" check internal/views
if [[ "$before" != "$after_first" || "$after_first" != "$after_second" ]]; then
echo "generated output was stale or nondeterministic" >&2
diff -u <(printf '%s\n' "$before") <(printf '%s\n' "$after_second") || true
exit 1
fi
go test ./...
go vet ./...
build_dir=$(mktemp -d)
trap 'rm -rf "$build_dir"' EXIT
go build -trimpath -o "$build_dir/site" ./cmd/site
dependencies=$(go list -deps ./cmd/site)
if ! grep -qx 'gamertan.com/sandwich-hime/sando' <<<"$dependencies"; then
echo "production dependency graph does not contain the sando runtime" >&2
exit 1
fi
if grep -Eq '^gamertan\.com/sandwich-hime$|^gamertan\.com/sandwich-hime/(cmd|internal)(/|$)' <<<"$dependencies"; then
echo "production dependency graph contains the Sandwich Hime compiler" >&2
exit 1
fi
echo "verified deterministic generation, tests, vet, build, and runtime-only production dependencies"