feat: publish the Sandwich Hime tutorial starter
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
# SPDX-License-Identifier: 0BSD
|
||||||
|
|
||||||
|
* text=auto eol=lf
|
||||||
|
*.sando.go linguist-generated=true
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
SPDX-License-Identifier: 0BSD
|
||||||
|
SPDX-FileCopyrightText: 2026 Cole Speelman
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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 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=<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.
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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(), "<script>") {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?sando go
|
||||||
|
package views
|
||||||
|
|
||||||
|
func Badge(label string)
|
||||||
|
?>
|
||||||
|
<?# SPDX-License-Identifier: 0BSD ?>
|
||||||
|
<span class="badge"><?= label ?></span>
|
||||||
Generated
+37
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Generated
+81
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Generated
+69
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, "<script>") {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+56
@@ -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"
|
||||||
Reference in New Issue
Block a user