app: rastrillo skeleton serving a page
rastrillo v0.21.0 pinned exactly -- below it the attribute grammar
renders unstyled. Follows SKILL.md's five-file shape (db.Open +
migrate.Apply, a chi router wrapped as *http.ServeMux, rastrillo's
shell partials and ui.TokensCSS()/ui.ThemeCSS("day") vendored at
runtime rather than copied to static/ by hand), adapted into
docs.New(cfg) (*App, error) / App.Handler() to match the test harness
(docs.NewForTest, app.Handler().ServeHTTP) that later tasks' tests all
depend on -- SKILL.md's own example names a bare App(d, origin,
logger) (*http.ServeMux, error) function instead.
Config carries Env and DevAuth now, unused by New, so NewForTest can
already set Env: "development" with dev auth enabled ahead of the
task that adds the identity plugin those fields configure, without a
later signature change.
GET / renders the app's first screen -- no document list or auth yet,
both out of scope for this task -- with every string a Catalog key
resolved through rastrillo.T, and its own content markup written in
the bare rst-* attribute grammar per this app's per-screen convention.
8 files changed,
+558
−4
cmd/docs/main.go+60 −0go.mod+22 −2go.sum+89 −2internal/docs/app.go+115 −0internal/docs/app_test.go+26 −0internal/docs/handlers.go+42 −0internal/docs/render.go+169 −0internal/docs/testing.go+35 −0
diff --git a/cmd/docs/main.go b/cmd/docs/main.go| new file mode 100644 |
| index 0000000..bbb7def |
| --- /dev/null |
| +++ b/cmd/docs/main.go |
| @@ -0,0 +1,60 @@ |
| +// Command docs runs the CARLOS Docs rastrillo app. |
| +package main |
| + |
| +import ( |
| + "log/slog" |
| + "net/http" |
| + "os" |
| + |
| + "github.com/carlosframework/rastrillo" |
| + |
| + "github.com/carlosframework/docs/internal/docs" |
| +) |
| + |
| +func main() { |
| + logger := slog.Default() |
| + |
| + // Resolve applies the platform's activation argv (-socket/-addr/-db, |
| + // or LISTEN_FDS/$STATE_DIRECTORY under the platform) — never |
| + // hand-rolled, per SKILL.md §1. |
| + opts, err := rastrillo.Resolve(rastrillo.Options{ |
| + DBPath: "docs.db", |
| + Logger: logger, |
| + }) |
| + if err != nil { |
| + logger.Error("docs: resolve", "err", err) |
| + os.Exit(1) |
| + } |
| + |
| + // New opens its own database handle (SKILL.md §1's "app that opens |
| + // its own database"), so opts.DBPath is blanked below before Serve |
| + // — otherwise Serve would open a second connection to the file New |
| + // already owns. |
| + // |
| + // Env is "production" until Task 5's devauth.go lands and this |
| + // reads a flag instead. |
| + app, err := docs.New(docs.Config{ |
| + DBPath: opts.DBPath, |
| + Env: "production", |
| + Logger: logger, |
| + }) |
| + if err != nil { |
| + logger.Error("docs: new", "err", err) |
| + os.Exit(1) |
| + } |
| + defer app.Close() |
| + |
| + // App.Handler() returns http.Handler (the interface the task's test |
| + // harness requires); Options.Mux wants the concrete *http.ServeMux |
| + // underneath it, which is exactly what App.Handler() always is. |
| + opts.Mux = app.Handler().(*http.ServeMux) |
| + opts.DBPath = "" |
| + opts.Locales = docs.Locales |
| + opts.DefaultLocale = docs.DefaultLocale |
| + opts.BaseCatalog = docs.Catalog |
| + |
| + if err := rastrillo.Serve(opts); err != nil { |
| + logger.Error("docs: serve", "err", err) |
| + os.Exit(1) |
| + } |
| +} |
diff --git a/go.mod b/go.mod| index 6398cb1..d17b7bd 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -2,6 +2,26 @@ module github.com/carlosframework/docs |
| go 1.26.5 |
| -require github.com/tetratelabs/wazero v1.12.0 |
| +require ( |
| + github.com/carlosframework/rastrillo v0.21.0 |
| + github.com/go-chi/chi/v5 v5.3.2 |
| + github.com/tetratelabs/wazero v1.12.0 |
| +) |
| -require golang.org/x/sys v0.44.0 // indirect |
| +require ( |
| + github.com/dustin/go-humanize v1.0.1 // indirect |
| + github.com/google/uuid v1.6.0 // indirect |
| + github.com/jinzhu/inflection v1.0.0 // indirect |
| + github.com/jinzhu/now v1.1.5 // indirect |
| + github.com/mattn/go-isatty v0.0.20 // indirect |
| + github.com/ncruces/go-strftime v1.0.0 // indirect |
| + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| + golang.org/x/sys v0.46.0 // indirect |
| + golang.org/x/text v0.20.0 // indirect |
| + gorm.io/gorm v1.31.2 // indirect |
| + gorm.io/plugin/dbresolver v1.6.2 // indirect |
| + modernc.org/libc v1.74.1 // indirect |
| + modernc.org/mathutil v1.7.1 // indirect |
| + modernc.org/memory v1.11.0 // indirect |
| + modernc.org/sqlite v1.55.0 // indirect |
| +) |
diff --git a/go.sum b/go.sum| index a10a279..035a8ec 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,4 +1,91 @@ |
| +github.com/carlosframework/rastrillo v0.21.0 h1:uqw/ZK+y/VwS+jDnIv2llAIug3AuFWesSp1xTEPS45w= |
| +github.com/carlosframework/rastrillo v0.21.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw= |
| +github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= |
| +github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= |
| +github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM= |
| +github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo= |
| +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= |
| +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= |
| +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
| +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
| +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= |
| +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= |
| +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= |
| +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= |
| +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= |
| +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= |
| +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= |
| +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= |
| +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= |
| +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= |
| +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= |
| +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= |
| +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= |
| +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= |
| +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
| +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
| +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= |
| +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= |
| +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= |
| +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= |
| +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= |
| +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= |
| +github.com/keymaildev/signin v0.1.1 h1:gO+1IAM99sqUkBtCezkgGxab+/DOrqwDxd5H32N1s9Q= |
| +github.com/keymaildev/signin v0.1.1/go.mod h1:Eb/sCmEel1jlcdkgPOrNeMn5jvxzoFvJrdjDUxOBHls= |
| +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
| +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
| +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= |
| +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= |
| +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= |
| +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= |
| github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= |
| -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= |
| -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= |
| +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= |
| +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= |
| +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= |
| +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= |
| +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= |
| +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= |
| +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= |
| +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= |
| +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= |
| +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= |
| +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= |
| +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= |
| +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= |
| +gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= |
| +gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= |
| +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= |
| +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= |
| +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= |
| +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= |
| +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= |
| +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= |
| +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= |
| +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= |
| +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= |
| +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= |
| +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= |
| +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= |
| +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= |
| +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= |
| +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= |
| +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= |
| +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= |
| +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= |
| +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= |
| +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= |
| +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= |
| +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= |
| +modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= |
| +modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= |
| +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= |
| +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= |
| +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= |
| +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= |
diff --git a/internal/docs/app.go b/internal/docs/app.go| new file mode 100644 |
| index 0000000..5711984 |
| --- /dev/null |
| +++ b/internal/docs/app.go |
| @@ -0,0 +1,115 @@ |
| +// Package docs is the CARLOS Docs rastrillo app: its database handle, |
| +// router and (for now, one) page. Follows rastrillo's SKILL.md app |
| +// shape (models.go, app.go, handlers.go, render.go, cmd/docs/main.go), |
| +// adapted into a New(cfg) (*App, error) constructor + App.Handler() |
| +// rather than SKILL.md's illustrative App(d, origin, logger) |
| +// (*http.ServeMux, error) function, because the task that specified |
| +// this app also specified the test harness (docs.NewForTest, |
| +// app.Handler().ServeHTTP) that every later task's tests depend on — |
| +// the constructor name and shape follow that harness; everything else |
| +// below follows SKILL.md. |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "log/slog" |
| + "net/http" |
| + |
| + "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/migrate" |
| + "github.com/go-chi/chi/v5" |
| +) |
| + |
| +// Config configures New. |
| +type Config struct { |
| + // DBPath is the SQLite file New opens via rastrillo's db package. |
| + DBPath string |
| + |
| + // Env is "development" or "production" ("" defaults to |
| + // "production"). A later task's development-only identity |
| + // (devauth.go) refuses to start unless Env is "development" — see |
| + // DevAuth. |
| + Env string |
| + |
| + // DevAuth enables that development-only identity. New itself does |
| + // nothing with it yet; the field exists here — rather than being |
| + // added when devauth.go lands — so NewForTest, which four later |
| + // tasks' tests already depend on, never needs a signature change. |
| + DevAuth bool |
| + |
| + // Logger defaults to slog.Default(). |
| + Logger *slog.Logger |
| +} |
| + |
| +// App is the CARLOS Docs rastrillo app. |
| +type App struct { |
| + cfg Config |
| + db *db.DB |
| + mux *http.ServeMux |
| + logger *slog.Logger |
| +} |
| + |
| +// schema is this app's own migrations. Empty until Task 2 adds |
| +// models.go; kept as its own var (rather than inlining into BootSchema) |
| +// so a later merge — migrate.Merge(sessions.Schema, schema), once auth |
| +// lands — has somewhere to plug in without renaming this one. |
| +var schema = migrate.Merge() |
| + |
| +// BootSchema is everything New applies at boot, in apply order. |
| +var BootSchema = migrate.Merge(schema) |
| + |
| +// New opens the database, applies migrations and wires the router. It |
| +// is rastrillo SKILL.md's app.go shape: db.Open for the SQLite handle, |
| +// migrate.Apply once at boot, a chi router wrapped as an |
| +// *http.ServeMux so httptest exercises the real router. It does not |
| +// wire sessions, CSRF or an identity plugin — this task has no |
| +// mutating route and no signed-in route yet, so there is nothing for |
| +// either to protect; both land with the tasks that add them. |
| +func New(cfg Config) (*App, error) { |
| + logger := cfg.Logger |
| + if logger == nil { |
| + logger = slog.Default() |
| + } |
| + if cfg.Env == "" { |
| + cfg.Env = "production" |
| + } |
| + |
| + d, err := db.Open(cfg.DBPath, logger) |
| + if err != nil { |
| + return nil, fmt.Errorf("docs: open database: %w", err) |
| + } |
| + |
| + if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil { |
| + d.Close() |
| + return nil, fmt.Errorf("docs: apply migrations: %w", err) |
| + } |
| + |
| + render, err := newRenderer() |
| + if err != nil { |
| + d.Close() |
| + return nil, fmt.Errorf("docs: build renderer: %w", err) |
| + } |
| + |
| + h := &handlers{render: render, logger: logger} |
| + |
| + r := chi.NewRouter() |
| + r.Get("/", h.home) |
| + r.Handle("/static/*", render.assets.Handler()) |
| + |
| + mux := http.NewServeMux() |
| + mux.Handle("/", r) |
| + |
| + return &App{cfg: cfg, db: d, mux: mux, logger: logger}, nil |
| +} |
| + |
| +// Handler is the app's router: rastrillo.Options.Mux for real serving |
| +// (cmd/docs/main.go), and — per rastrillo's own doc comment on |
| +// Handler/Serve — exactly what a test harness should exercise directly, |
| +// so httptest hits the real route table rather than a hand-duplicated |
| +// one. |
| +func (a *App) Handler() http.Handler { return a.mux } |
| + |
| +// Close releases the database handle. NewForTest registers this as a |
| +// t.Cleanup. |
| +func (a *App) Close() error { return a.db.Close() } |
diff --git a/internal/docs/app_test.go b/internal/docs/app_test.go| new file mode 100644 |
| index 0000000..63946e5 |
| --- /dev/null |
| +++ b/internal/docs/app_test.go |
| @@ -0,0 +1,26 @@ |
| +package docs_test |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + |
| + "github.com/carlosframework/docs/internal/docs" |
| +) |
| + |
| +func TestRootRendersAPage(t *testing.T) { |
| + app, err := docs.NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + req := httptest.NewRequest(http.MethodGet, "/", nil) |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET /: status %d, want 200", rec.Code) |
| + } |
| + if ct := rec.Header().Get("Content-Type"); ct == "" { |
| + t.Error("no Content-Type on the response") |
| + } |
| +} |
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go| new file mode 100644 |
| index 0000000..1c6856d |
| --- /dev/null |
| +++ b/internal/docs/handlers.go |
| @@ -0,0 +1,42 @@ |
| +package docs |
| + |
| +import ( |
| + "log/slog" |
| + "net/http" |
| + |
| + "github.com/carlosframework/rastrillo" |
| +) |
| + |
| +// handlers holds what the app's HTTP handlers need — rastrillo |
| +// SKILL.md's "one handler struct holding d.G", minus d.G: Task 1 has |
| +// no data model yet, so it carries only the renderer. Task 2 adds a |
| +// *gorm.DB-backed store here. |
| +type handlers struct { |
| + render *renderer |
| + logger *slog.Logger |
| +} |
| + |
| +// homePage is GET /'s view data. Every field is resolved through |
| +// rastrillo.T from Catalog (render.go) rather than written as a |
| +// literal — see homeTemplateSrc, which only ever prints these fields, |
| +// never a string of its own. |
| +type homePage struct { |
| + Title string |
| + Lede string |
| + Body string |
| +} |
| + |
| +// home serves GET /: the app's first screen, rendered through |
| +// rastrillo's shell. Tasks 3 and 4 replace this with the document |
| +// list and, for now, this is the whole app. |
| +func (h *handlers) home(w http.ResponseWriter, r *http.Request) { |
| + data := homePage{ |
| + Title: rastrillo.T(r, "docs.home.title"), |
| + Lede: rastrillo.T(r, "docs.home.lede"), |
| + Body: rastrillo.T(r, "docs.home.body"), |
| + } |
| + if err := h.render.page(w, r, "home", data); err != nil { |
| + h.logger.Error("docs: render home", "err", err) |
| + http.Error(w, "Something went wrong.", http.StatusInternalServerError) |
| + } |
| +} |
diff --git a/internal/docs/render.go b/internal/docs/render.go| new file mode 100644 |
| index 0000000..c87f39e |
| --- /dev/null |
| +++ b/internal/docs/render.go |
| @@ -0,0 +1,169 @@ |
| +package docs |
| + |
| +import ( |
| + "bytes" |
| + "fmt" |
| + "html/template" |
| + "io/fs" |
| + "net/http" |
| + "testing/fstest" |
| + |
| + "github.com/carlosframework/rastrillo" |
| + "github.com/carlosframework/rastrillo/ui" |
| +) |
| + |
| +// dayTheme and shellLayout pick the design system's day theme and |
| +// column shell — rastrillo new's own defaults, and what the design |
| +// spec (docs/superpowers/specs/2026-08-30-docs-design.md, §10) asks |
| +// for. |
| +const ( |
| + dayTheme = "day" |
| + shellLayout = "column" |
| +) |
| + |
| +// Catalog is CARLOS Docs' own English strings. Every user-visible |
| +// string this app writes is a key here (never a literal in a |
| +// template or a handler), resolved through rastrillo.T at render |
| +// time. cmd/docs/main.go wires it in as rastrillo.Options.BaseCatalog. |
| +var Catalog = rastrillo.Catalog{ |
| + "docs.home.title": "CARLOS Docs", |
| + "docs.home.lede": "A document editor, warming up.", |
| + "docs.home.body": "This is the app's first screen: routed, and rendered " + |
| + "through rastrillo's shell. Documents, and a way to sign in, land in " + |
| + "the tasks that follow.", |
| +} |
| + |
| +// Locales and DefaultLocale declare this app's locale set for |
| +// cmd/docs/main.go's rastrillo.Options — one locale today. Without |
| +// Options.Locales set, rastrillo installs no locale middleware and |
| +// rastrillo.T falls back to returning its key verbatim (documented, |
| +// harmless — it's what a direct App.Handler() call in a test sees, |
| +// never having gone through that middleware); main.go wiring these in |
| +// is what makes Catalog's strings actually resolve on the real page. |
| +var Locales = []string{"en"} |
| + |
| +const DefaultLocale = "en" |
| + |
| +// pageTemplates holds one Go template source per page, by name. Per |
| +// SKILL.md's render.go: "parses one *template.Template per page |
| +// (layout + page), so two pages can both define \"content\"" — a |
| +// shared tree could not, since html/template forbids two |
| +// {{define "content"}} blocks in one tree. Task 3 and Task 4 add |
| +// "list" and "show" here. |
| +var pageTemplates = map[string]string{ |
| + "home": homeTemplateSrc, |
| +} |
| + |
| +// homeTemplateSrc is GET /'s page. It writes the bare rst-* attribute |
| +// grammar (never class="rst-*") per this app's markup convention, |
| +// adopted for the whole screen — rastrillo's own shell chrome |
| +// (rastrillo's Layout, parsed in below) keeps its own class spelling, |
| +// since that is vendored infrastructure this app calls, not markup it |
| +// authors. |
| +const homeTemplateSrc = ` |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}}<header rst-page-header> |
| + <div rst-page-header-titles> |
| + <h1>{{.Title}}</h1> |
| + <p rst-page-header-sub>{{.Lede}}</p> |
| + </div> |
| +</header> |
| +<section rst-box> |
| + <p>{{.Body}}</p> |
| +</section> |
| +{{end}} |
| +` |
| + |
| +// renderer owns the app's parsed templates and its fingerprinted |
| +// static-asset registry. |
| +type renderer struct { |
| + pages map[string]*template.Template |
| + assets *rastrillo.Assets |
| +} |
| + |
| +// newRenderer builds the renderer: the vendored design-system CSS/JS |
| +// as an in-memory static tree, rastrillo's shell partials and layout, |
| +// and this app's own pages, each cloned from the same base so every |
| +// page shares helpers and the shell without sharing a "content" |
| +// definition. |
| +func newRenderer() (*renderer, error) { |
| + staticFS, err := vendoredStaticFS() |
| + if err != nil { |
| + return nil, err |
| + } |
| + assets := rastrillo.NewAssets(staticFS) |
| + |
| + base := template.New("").Funcs(ui.Funcs()).Funcs(template.FuncMap{ |
| + "asset": assets.Path, |
| + }) |
| + base, err = base.ParseFS(ui.Templates(), "*.html") |
| + if err != nil { |
| + return nil, fmt.Errorf("docs: parse rastrillo ui partials: %w", err) |
| + } |
| + |
| + layout, ok := ui.Layout(shellLayout) |
| + if !ok { |
| + return nil, fmt.Errorf("docs: rastrillo ui: shell %q not shipped", shellLayout) |
| + } |
| + base, err = base.Parse(string(layout)) |
| + if err != nil { |
| + return nil, fmt.Errorf("docs: parse rastrillo shell %q: %w", shellLayout, err) |
| + } |
| + |
| + pages := make(map[string]*template.Template, len(pageTemplates)) |
| + for name, src := range pageTemplates { |
| + page, err := base.Clone() |
| + if err != nil { |
| + return nil, fmt.Errorf("docs: clone base template for %q: %w", name, err) |
| + } |
| + if page, err = page.Parse(src); err != nil { |
| + return nil, fmt.Errorf("docs: parse page %q: %w", name, err) |
| + } |
| + pages[name] = page |
| + } |
| + |
| + return &renderer{pages: pages, assets: assets}, nil |
| +} |
| + |
| +// page renders the named page's "layout" template — its own content |
| +// wrapped in rastrillo's shell — into w. |
| +func (rd *renderer) page(w http.ResponseWriter, r *http.Request, name string, data any) error { |
| + tmpl, ok := rd.pages[name] |
| + if !ok { |
| + return fmt.Errorf("docs: unknown page %q", name) |
| + } |
| + // Render into a buffer first: a mid-template error must not leave |
| + // a half-written 200 on the wire. |
| + var buf bytes.Buffer |
| + if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil { |
| + return fmt.Errorf("docs: render page %q: %w", name, err) |
| + } |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + _, err := buf.WriteTo(w) |
| + return err |
| +} |
| + |
| +// vendoredStaticFS builds the static/ tree rastrillo.Assets serves — |
| +// tokens.css and the day theme via ui.TokensCSS()/ui.ThemeCSS("day") |
| +// as the brief requires, plus the three scripts the shell's <head> |
| +// links unconditionally (ui.ShimJS/SelectJS/DatetimeJS) — as bytes |
| +// taken straight from the rastrillo module. Nothing here is copied to |
| +// static/ by hand: rastrillo new would normally vendor these to disk |
| +// once at scaffold time (ui.VendoredAssets's own doc comment), but |
| +// this app was hand-built rather than scaffolded, so it takes the |
| +// same bytes at process start instead, from an in-memory fs.FS. |
| +// testing/fstest.MapFS is the standard library's only in-memory |
| +// fs.FS — used here as a data structure, not as a test helper. |
| +func vendoredStaticFS() (fs.FS, error) { |
| + theme, ok := ui.ThemeCSS(dayTheme) |
| + if !ok { |
| + return nil, fmt.Errorf("docs: rastrillo ui: theme %q not shipped", dayTheme) |
| + } |
| + return fstest.MapFS{ |
| + "static/tokens.css": {Data: ui.TokensCSS()}, |
| + "static/theme.css": {Data: theme}, |
| + "static/rastrillo.js": {Data: ui.ShimJS()}, |
| + "static/select.js": {Data: ui.SelectJS()}, |
| + "static/datetime.js": {Data: ui.DatetimeJS()}, |
| + }, nil |
| +} |
diff --git a/internal/docs/testing.go b/internal/docs/testing.go| new file mode 100644 |
| index 0000000..c1cc6ad |
| --- /dev/null |
| +++ b/internal/docs/testing.go |
| @@ -0,0 +1,35 @@ |
| +package docs |
| + |
| +import ( |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +// NewForTest builds an App against a temporary SQLite database. It is |
| +// the constructor every later task's tests use — deliberately not in a |
| +// _test.go file, so packages other than this one's own tests can reach |
| +// it too. |
| +// |
| +// Env is fixed at "development" with DevAuth enabled: a later task |
| +// (devauth.go) adds a development-only identity that refuses to start |
| +// outside development, and every test built on NewForTest — this |
| +// task's own and four later tasks' — assumes that identity is already |
| +// reachable rather than needing its own opt-in. |
| +func NewForTest(t *testing.T) (*App, error) { |
| + t.Helper() |
| + |
| + app, err := New(Config{ |
| + DBPath: filepath.Join(t.TempDir(), "docs.db"), |
| + Env: "development", |
| + DevAuth: true, |
| + }) |
| + if err != nil { |
| + return nil, err |
| + } |
| + t.Cleanup(func() { |
| + if cerr := app.Close(); cerr != nil { |
| + t.Errorf("docs.App.Close: %v", cerr) |
| + } |
| + }) |
| + return app, nil |
| +} |