From ecca0a37060989ee1414272dda07e59a0ac26457 Mon Sep 17 00:00:00 2001 From: l-you Date: Tue, 28 Apr 2026 17:31:44 +0000 Subject: [PATCH 1/2] feat(routes): generate not-found metadata resolvers Add MetaGen...NotFound to generated resolver contracts and compose 404 head metadata through the layout chain before applying noindex assets. Use a runtime-aware not-found callback so 404 metadata receives the same MetaContext as page metadata. Cover the behavior with a dedicated e2e fixture whose 404 model has no LayoutPageTitle fallback. AI-Impact: co-developed AI: cx --- docs/app/conventions.md | 8 + docs/app/features/i18n.md | 27 +++- docs/app/features/metadata-and-head.md | 17 +++ docs/app/features/routing-and-generation.md | 16 +- docs/app/getting-started.md | 13 ++ docs/app/migrations.md | 14 +- docs/app/migrations/v1.3.0-to-next.md | 40 ++++- e2e/fixture_loaders_test.go | 18 +++ e2e/notfoundmetadata_e2e_test.go | 47 ++++++ .../web/resolvers/not_found_metadata.go | 15 ++ .../web/resolvers/not_found_metadata.go | 15 ++ .../web/resolvers/not_found_metadata.go | 15 ++ .../web/resolvers/not_found_metadata.go | 15 ++ .../web/resolvers/not_found_metadata.go | 23 +++ .../web/resolvers/not_found_metadata.go | 15 ++ .../web/resolvers/not_found_metadata.go | 26 ++++ .../web/resolvers/not_found_metadata.go | 15 ++ .../web/resolvers/not_found_metadata.go | 15 ++ .../notfoundmetadataapp/cmd/server/main.go | 68 +++++++++ e2e/testdata/notfoundmetadataapp/go.mod | 26 ++++ e2e/testdata/notfoundmetadataapp/go.sum | 58 +++++++ .../notfoundmetadataapp/web/resolvers/root.go | 118 +++++++++++++++ .../notfoundmetadataapp/web/routes/404.templ | 9 ++ .../web/routes/docs/404.templ | 9 ++ .../web/routes/docs/fail/page.templ | 7 + .../web/routes/docs/layout.templ | 9 ++ .../web/routes/docs/page.templ | 7 + .../notfoundmetadataapp/web/routes/page.templ | 7 + .../notfoundmetadataapp/web/routes/root.templ | 14 ++ .../notfoundmetadataapp/web/view/context.go | 13 ++ .../web/view/view_models.go | 13 ++ .../web/resolvers/not_found_metadata.go | 15 ++ .../web/resolvers/not_found_metadata.go | 15 ++ .../web/resolvers/not_found_metadata.go | 15 ++ .../web/resolvers/not_found_metadata.go | 15 ++ .../web/resolvers/not_found_metadata.go | 15 ++ framework/httpserver/httpserver.go | 59 ++++++-- framework/httpserver/newapp.go | 48 +++--- .../approutegen/bundle_generation_test.go | 3 +- internal/bundler/approutegen/not_found_gen.go | 141 ++++++++++++++++-- internal/bundler/approutegen/page_gen.go | 4 + internal/bundler/approutegen/registry_gen.go | 9 +- .../approutegen/registry_generation_test.go | 8 +- internal/bundler/approutegen/resolvers_gen.go | 14 ++ 44 files changed, 995 insertions(+), 78 deletions(-) create mode 100644 e2e/notfoundmetadata_e2e_test.go create mode 100644 e2e/testdata/catchallapp/web/resolvers/not_found_metadata.go create mode 100644 e2e/testdata/clientassetsapp/web/resolvers/not_found_metadata.go create mode 100644 e2e/testdata/clientassetsslotgroupapp/web/resolvers/not_found_metadata.go create mode 100644 e2e/testdata/customruntimeapp/web/resolvers/not_found_metadata.go create mode 100644 e2e/testdata/docsfeatureapp/web/resolvers/not_found_metadata.go create mode 100644 e2e/testdata/groupednamespaceapp/web/resolvers/not_found_metadata.go create mode 100644 e2e/testdata/i18nprefixalwaysapp/web/resolvers/not_found_metadata.go create mode 100644 e2e/testdata/methodmatrixapp/web/resolvers/not_found_metadata.go create mode 100644 e2e/testdata/namespacedtemplcssapp/web/resolvers/not_found_metadata.go create mode 100644 e2e/testdata/notfoundmetadataapp/cmd/server/main.go create mode 100644 e2e/testdata/notfoundmetadataapp/go.mod create mode 100644 e2e/testdata/notfoundmetadataapp/go.sum create mode 100644 e2e/testdata/notfoundmetadataapp/web/resolvers/root.go create mode 100644 e2e/testdata/notfoundmetadataapp/web/routes/404.templ create mode 100644 e2e/testdata/notfoundmetadataapp/web/routes/docs/404.templ create mode 100644 e2e/testdata/notfoundmetadataapp/web/routes/docs/fail/page.templ create mode 100644 e2e/testdata/notfoundmetadataapp/web/routes/docs/layout.templ create mode 100644 e2e/testdata/notfoundmetadataapp/web/routes/docs/page.templ create mode 100644 e2e/testdata/notfoundmetadataapp/web/routes/page.templ create mode 100644 e2e/testdata/notfoundmetadataapp/web/routes/root.templ create mode 100644 e2e/testdata/notfoundmetadataapp/web/view/context.go create mode 100644 e2e/testdata/notfoundmetadataapp/web/view/view_models.go create mode 100644 e2e/testdata/optionalcatchallapp/web/resolvers/not_found_metadata.go create mode 100644 e2e/testdata/routepagecssapp/web/resolvers/not_found_metadata.go create mode 100644 e2e/testdata/templcssapp/web/resolvers/not_found_metadata.go create mode 100644 e2e/testdata/templrulesapp/web/resolvers/not_found_metadata.go create mode 100644 e2e/testdata/typedmodelsapp/web/resolvers/not_found_metadata.go diff --git a/docs/app/conventions.md b/docs/app/conventions.md index 23c7c17..7ce1595 100644 --- a/docs/app/conventions.md +++ b/docs/app/conventions.md @@ -248,6 +248,14 @@ templ NotFound(model view.NotFoundView, path string) {} Example generated resolver shape: ```go +func (Resolver) MetaGenRootNotFound( + meta framework.MetaContext[*view.Context], + notFound framework.NotFoundContext, + params RootParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Not Found"}, nil +} + func (Resolver) ResolveRootNotFound( ctx context.Context, appCtx *view.Context, diff --git a/docs/app/features/i18n.md b/docs/app/features/i18n.md index c6e1a65..3858a5e 100644 --- a/docs/app/features/i18n.md +++ b/docs/app/features/i18n.md @@ -92,8 +92,22 @@ unless you choose to use built-in i18n in app code. 404 note: - `404.templ` uses the same generated resolver pattern as pages and layouts -- if a localized or request-aware 404 needs app data, return that data from the - generated `Resolve...NotFound(...)` method: +- if a localized 404 needs metadata, return it from the generated + `MetaGen...NotFound(...)` method: + +```go +func (Resolver) MetaGenRootNotFound( + meta framework.MetaContext[*view.Context], + notFound framework.NotFoundContext, + params RootParams, +) (metagen.Metadata, error) { + tr := meta.App().I18n(meta.Request()) + return metagen.Metadata{Title: i18n.TUiNotFoundTitle(tr)}, nil +} +``` + +- if a localized or request-aware 404 body needs app data, return that data from + the generated `Resolve...NotFound(...)` method: ```go func (Resolver) ResolveRootNotFound( @@ -105,17 +119,18 @@ func (Resolver) ResolveRootNotFound( ) (view.NotFoundView, error) { tr := appCtx.I18n(r) return view.NotFoundView{ - Title: i18n.TUiNotFoundTitle(tr), Heading: i18n.TUiNotFoundHeading(tr), }, nil } ``` -- nested route-local 404 pages get their own generated resolver method, such as +- nested route-local 404 pages get their own generated resolver methods, such as + `MetaGenGroupSupportHelpNotFound(...)` and `ResolveGroupSupportHelpNotFound(...)` - for unmatched localized 404s, generation enriches the request context with the - resolved locale before `Resolve...NotFound(...)` runs, so `appCtx.I18n(r)` - still resolves the right language + resolved locale before the generated not-found metadata and view resolvers + run, so `meta.App().I18n(meta.Request())` and `appCtx.I18n(r)` still resolve + the right language - 500 pages are intentionally not localized by the generated resolver contract; use `httpserver.CustomConfig.ServerErrorPage` for a generic custom 500 page - if you are migrating an older app, use diff --git a/docs/app/features/metadata-and-head.md b/docs/app/features/metadata-and-head.md index 9f1d6e9..709e8e2 100644 --- a/docs/app/features/metadata-and-head.md +++ b/docs/app/features/metadata-and-head.md @@ -69,6 +69,23 @@ func (Resolver) MetaGenAuthorParamSlugPage( This keeps canonical URLs, localized URLs, and alternates on the same site-root policy. +## 404 Metadata + +Generated 404 rendering uses `MetaGen...NotFound(...)` methods for not-found +head fields. The metadata chain is root layout, matched route layouts, then the +matched 404 metadata resolver. Generated rendering still applies +`noindex, nofollow` to 404 responses. + +```go +func (Resolver) MetaGenRootNotFound( + meta framework.MetaContext[*view.Context], + notFound framework.NotFoundContext, + params RootParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Not Found"}, nil +} +``` + ## Managed Client Assets `@metagen.Head(meta)` is also where framework-managed Client Assets appear. diff --git a/docs/app/features/routing-and-generation.md b/docs/app/features/routing-and-generation.md index 8cf6ca1..b230498 100644 --- a/docs/app/features/routing-and-generation.md +++ b/docs/app/features/routing-and-generation.md @@ -120,9 +120,18 @@ templ NotFound(model view.HelpNotFoundView, path string) { } ``` -Generation adds a matching resolver method for that route-local 404: +Generation adds matching metadata and view resolver methods for that +route-local 404: ```go +func (Resolver) MetaGenGroupSupportHelpNotFound( + meta framework.MetaContext[*view.Context], + notFound framework.NotFoundContext, + params GroupSupportHelpParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Missing help page"}, nil +} + func (Resolver) ResolveGroupSupportHelpNotFound( ctx context.Context, appCtx *view.Context, @@ -135,8 +144,9 @@ func (Resolver) ResolveGroupSupportHelpNotFound( ``` If the app uses built-in i18n, generated not-found rendering resolves the locale -before `Resolve...NotFound(...)` runs. Use that resolver when the 404 view model -needs translations, localized URLs, or request-scoped data. +before `MetaGen...NotFound(...)` and `Resolve...NotFound(...)` run. Use the +metadata resolver for the 404 `` and head fields, and the view resolver +when the 404 body needs translations, localized URLs, or request-scoped data. ## Method Routes diff --git a/docs/app/getting-started.md b/docs/app/getting-started.md index 1b930a7..2db3421 100644 --- a/docs/app/getting-started.md +++ b/docs/app/getting-started.md @@ -225,6 +225,11 @@ type RootParams struct{} type RouteResolver interface { MetaGenRootLayout(meta framework.MetaContext[*view.Context]) (metagen.Metadata, error) MetaGenRootPage(meta framework.MetaContext[*view.Context], params RootParams) (metagen.Metadata, error) + MetaGenRootNotFound( + meta framework.MetaContext[*view.Context], + notFound framework.NotFoundContext, + params RootParams, + ) (metagen.Metadata, error) ResolveRootPage( ctx context.Context, appCtx *view.Context, @@ -281,6 +286,14 @@ func (Resolver) ResolveRootPage( }, nil } +func (Resolver) MetaGenRootNotFound( + meta framework.MetaContext[*view.Context], + notFound framework.NotFoundContext, + params RootParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Not Found"}, nil +} + func (Resolver) ResolveRootNotFound( ctx context.Context, appCtx *view.Context, diff --git a/docs/app/migrations.md b/docs/app/migrations.md index b3e0817..020d378 100644 --- a/docs/app/migrations.md +++ b/docs/app/migrations.md @@ -8,10 +8,12 @@ temporary and must be renamed to the concrete release version after release. ## Next -- [v1.3.0 to Next: View Package, Typed System Pages, Server Error UI, Templ CSS Config, And App Shape](migrations/v1.3.0-to-next.md) +- [v1.3.0 to Next: Typed System Pages, 404 Metadata, Server Errors, + Templ CSS, And App Shape](migrations/v1.3.0-to-next.md) Rename app `web/view` packages to `package view`, move 404 model construction - into generated `Resolve...NotFound(...)` resolver methods, move custom 500 UI - to `httpserver.CustomConfig.ServerErrorPage`, remove `-templ-css` now that - global templ CSS extraction is enabled by default and disabled with - `assets.templ_css: false`, and clean unsupported files from `web/routes` - and `web/components`. + into generated `Resolve...NotFound(...)` resolver methods, add generated + `MetaGen...NotFound(...)` methods for 404 head metadata, move custom 500 UI to + `httpserver.CustomConfig.ServerErrorPage`, remove `-templ-css` now that global + templ CSS extraction is enabled by default and disabled with + `assets.templ_css: false`, and clean unsupported files from `web/routes` and + `web/components`. diff --git a/docs/app/migrations/v1.3.0-to-next.md b/docs/app/migrations/v1.3.0-to-next.md index 12354c1..b0f822d 100644 --- a/docs/app/migrations/v1.3.0-to-next.md +++ b/docs/app/migrations/v1.3.0-to-next.md @@ -1,10 +1,10 @@ -# Migrate v1.3.0 to Next: View Package, Typed System Pages, Server Error UI, Templ CSS Config, And App Shape +# Migrate v1.3.0 to Next: Typed System Pages, 404 Metadata, Server Errors, Templ CSS, And App Shape Applies to: upgrading from `v1.3.0` or earlier to the first release after `v1.3.0` that includes the `web/view` package-name contract, typed generated -404 resolver contract, request-aware server error logging, optional server -error UI, default global templ CSS extraction, and strict `web/routes` and -`web/components` generation input validation. +404 resolver contract, generated 404 metadata, request-aware server error +logging, optional server error UI, default global templ CSS extraction, and +strict `web/routes` and `web/components` generation input validation. ## What Changed @@ -12,6 +12,8 @@ error UI, default global templ CSS extraction, and strict `web/routes` and - generated 404 rendering no longer calls `view.NewNotFoundView()` - `404.templ` declares its own `view.*` model, and generation requires the matching `Resolve...NotFound(...)` resolver method +- generated 404 rendering now gets `<title>` and other head fields from the + matching `MetaGen...NotFound(...)` resolver method - `RootLayoutView` is no longer a base framework contract; keep it only if your app templates still use it - `web/routes/error.templ` is no longer required or wired by route generation @@ -114,7 +116,7 @@ Affected files: - `web/view/*.go` - `web/routes/404.templ` - nested `web/routes/**/404.templ` -- `web/resolvers/*.go` when localized 404 data is needed +- `web/resolvers/*.go` Old shape: @@ -147,6 +149,14 @@ type NotFoundView struct { ``` ```go +func (Resolver) MetaGenRootNotFound( + meta framework.MetaContext[*view.Context], + notFound framework.NotFoundContext, + params RootParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Not Found"}, nil +} + func (Resolver) ResolveRootNotFound( ctx context.Context, appCtx *view.Context, @@ -159,9 +169,18 @@ func (Resolver) ResolveRootNotFound( ``` If your 404 page needs request-scoped data such as locale, read it in the -generated resolver method: +generated metadata or view resolver method: ```go +func (Resolver) MetaGenRootNotFound( + meta framework.MetaContext[*view.Context], + notFound framework.NotFoundContext, + params RootParams, +) (metagen.Metadata, error) { + tr := meta.App().I18n(meta.Request()) + return metagen.Metadata{Title: i18n.TUiNotFoundTitle(tr)}, nil +} + func (Resolver) ResolveRootNotFound( ctx context.Context, appCtx *view.Context, @@ -181,6 +200,12 @@ Nested route-local 404 files get route-specific methods. For example, `web/routes/_group__support/help/404.templ` generates: ```go +func (Resolver) MetaGenGroupSupportHelpNotFound( + meta framework.MetaContext[*view.Context], + notFound framework.NotFoundContext, + params GroupSupportHelpParams, +) (metagen.Metadata, error) + func (Resolver) ResolveGroupSupportHelpNotFound( ctx context.Context, appCtx *view.Context, @@ -530,7 +555,8 @@ Your app is migrated when: - imports of the app view package are unaliased unless there is a documented versioned import-path exception - `404.templ` uses an app-owned `view.*` model type -- `web/resolvers` implements each generated `Resolve...NotFound(...)` method +- `web/resolvers` implements each generated `MetaGen...NotFound(...)` and + `Resolve...NotFound(...)` method - `RootLayoutView` remains only if your app templates still use it - `web/routes/error.templ` is not needed for generation - any custom 500 UI is configured through `httpserver.CustomConfig.ServerErrorPage` diff --git a/e2e/fixture_loaders_test.go b/e2e/fixture_loaders_test.go index d942e24..4ec04e0 100644 --- a/e2e/fixture_loaders_test.go +++ b/e2e/fixture_loaders_test.go @@ -169,6 +169,12 @@ type typedModelsFixture struct { NotFound responseSnapshot } +type notFoundMetadataFixture struct { + Home responseSnapshot + RootMissing responseSnapshot + DocsMissing responseSnapshot +} + type streamSnapshot struct { Status int ContentType string @@ -543,6 +549,18 @@ func loadTypedModelsFixture(t *testing.T) typedModelsFixture { } } +func loadNotFoundMetadataFixture(t *testing.T) notFoundMetadataFixture { + t.Helper() + + _, server := startPreparedFixture(t, "notfoundmetadataapp") + + return notFoundMetadataFixture{ + Home: requestFixture(t, server, http.MethodGet, "/", nil, requestOptions{}), + RootMissing: requestFixture(t, server, http.MethodGet, "/missing", nil, requestOptions{}), + DocsMissing: requestFixture(t, server, http.MethodGet, "/docs/fail", nil, requestOptions{}), + } +} + func loadTemplRulesFixture(t *testing.T) templRulesFixture { t.Helper() diff --git a/e2e/notfoundmetadata_e2e_test.go b/e2e/notfoundmetadata_e2e_test.go new file mode 100644 index 0000000..38481c2 --- /dev/null +++ b/e2e/notfoundmetadata_e2e_test.go @@ -0,0 +1,47 @@ +package e2e + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNotFoundMetadataFixtureApp(t *testing.T) { + report := loadNotFoundMetadataFixture(t) + + require.Equal(t, 200, report.Home.Status) + require.Contains(t, report.Home.Body, `<title data-metagen-managed="true">Not Found Metadata Home`) + + require.Equal(t, 404, report.RootMissing.Status) + require.Contains(t, report.RootMissing.Body, `id="root-not-found"`) + require.Contains(t, report.RootMissing.Body, `Root missing /missing`) + require.Contains(t, report.RootMissing.Body, `Root 404 Metadata Title`) + require.Contains( + t, + report.RootMissing.Body, + ``, + ) + require.Contains( + t, + report.RootMissing.Body, + ``, + ) + require.Contains( + t, + report.RootMissing.Body, + ``, + ) + + require.Equal(t, 404, report.DocsMissing.Status) + require.Contains(t, report.DocsMissing.Body, `data-layout="docs"`) + require.Contains(t, report.DocsMissing.Body, `id="docs-not-found"`) + require.Contains(t, report.DocsMissing.Body, `Docs missing /docs/fail`) + require.Contains(t, report.DocsMissing.Body, `Docs 404 Metadata Title`) + require.Contains( + t, + report.DocsMissing.Body, + ``, + ) + require.Equal(t, 1, strings.Count(report.DocsMissing.Body, ``)) +} diff --git a/e2e/testdata/catchallapp/web/resolvers/not_found_metadata.go b/e2e/testdata/catchallapp/web/resolvers/not_found_metadata.go new file mode 100644 index 0000000..f739803 --- /dev/null +++ b/e2e/testdata/catchallapp/web/resolvers/not_found_metadata.go @@ -0,0 +1,15 @@ +package resolvers + +import ( + "example.com/no-js-e2e/catchallapp/web/view" + "github.com/RevoTale/no-js/framework" + "github.com/RevoTale/no-js/framework/metagen" +) + +func (Resolver) MetaGenRootNotFound( + _ framework.MetaContext[*view.Context], + _ framework.NotFoundContext, + _ RootParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Not Found"}, nil +} diff --git a/e2e/testdata/clientassetsapp/web/resolvers/not_found_metadata.go b/e2e/testdata/clientassetsapp/web/resolvers/not_found_metadata.go new file mode 100644 index 0000000..93c8b24 --- /dev/null +++ b/e2e/testdata/clientassetsapp/web/resolvers/not_found_metadata.go @@ -0,0 +1,15 @@ +package resolvers + +import ( + "example.com/no-js-e2e/clientassetsapp/web/view" + "github.com/RevoTale/no-js/framework" + "github.com/RevoTale/no-js/framework/metagen" +) + +func (Resolver) MetaGenRootNotFound( + _ framework.MetaContext[*view.Context], + _ framework.NotFoundContext, + _ RootParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Not Found"}, nil +} diff --git a/e2e/testdata/clientassetsslotgroupapp/web/resolvers/not_found_metadata.go b/e2e/testdata/clientassetsslotgroupapp/web/resolvers/not_found_metadata.go new file mode 100644 index 0000000..b3a943e --- /dev/null +++ b/e2e/testdata/clientassetsslotgroupapp/web/resolvers/not_found_metadata.go @@ -0,0 +1,15 @@ +package resolvers + +import ( + "example.com/no-js-e2e/clientassetsslotgroupapp/web/view" + "github.com/RevoTale/no-js/framework" + "github.com/RevoTale/no-js/framework/metagen" +) + +func (Resolver) MetaGenRootNotFound( + _ framework.MetaContext[*view.Context], + _ framework.NotFoundContext, + _ RootParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Not Found"}, nil +} diff --git a/e2e/testdata/customruntimeapp/web/resolvers/not_found_metadata.go b/e2e/testdata/customruntimeapp/web/resolvers/not_found_metadata.go new file mode 100644 index 0000000..ee3b80d --- /dev/null +++ b/e2e/testdata/customruntimeapp/web/resolvers/not_found_metadata.go @@ -0,0 +1,15 @@ +package resolvers + +import ( + "example.com/no-js-e2e/customruntimeapp/web/view" + "github.com/RevoTale/no-js/framework" + "github.com/RevoTale/no-js/framework/metagen" +) + +func (Resolver) MetaGenRootNotFound( + _ framework.MetaContext[*view.Context], + _ framework.NotFoundContext, + _ RootParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Not Found"}, nil +} diff --git a/e2e/testdata/docsfeatureapp/web/resolvers/not_found_metadata.go b/e2e/testdata/docsfeatureapp/web/resolvers/not_found_metadata.go new file mode 100644 index 0000000..68d0cbe --- /dev/null +++ b/e2e/testdata/docsfeatureapp/web/resolvers/not_found_metadata.go @@ -0,0 +1,23 @@ +package resolvers + +import ( + "example.com/no-js-e2e/docsfeatureapp/web/view" + "github.com/RevoTale/no-js/framework" + "github.com/RevoTale/no-js/framework/metagen" +) + +func (Resolver) MetaGenRootNotFound( + _ framework.MetaContext[*view.Context], + _ framework.NotFoundContext, + _ RootParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Not Found"}, nil +} + +func (Resolver) MetaGenAuthorParamSlugNotFound( + _ framework.MetaContext[*view.Context], + _ framework.NotFoundContext, + _ AuthorParamSlugParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Unknown Author"}, nil +} diff --git a/e2e/testdata/groupednamespaceapp/web/resolvers/not_found_metadata.go b/e2e/testdata/groupednamespaceapp/web/resolvers/not_found_metadata.go new file mode 100644 index 0000000..5826dad --- /dev/null +++ b/e2e/testdata/groupednamespaceapp/web/resolvers/not_found_metadata.go @@ -0,0 +1,15 @@ +package resolvers + +import ( + "example.com/no-js-e2e/groupednamespaceapp/web/view" + "github.com/RevoTale/no-js/framework" + "github.com/RevoTale/no-js/framework/metagen" +) + +func (Resolver) MetaGenRootNotFound( + _ framework.MetaContext[*view.Context], + _ framework.NotFoundContext, + _ RootParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Not Found"}, nil +} diff --git a/e2e/testdata/i18nprefixalwaysapp/web/resolvers/not_found_metadata.go b/e2e/testdata/i18nprefixalwaysapp/web/resolvers/not_found_metadata.go new file mode 100644 index 0000000..edc80d3 --- /dev/null +++ b/e2e/testdata/i18nprefixalwaysapp/web/resolvers/not_found_metadata.go @@ -0,0 +1,26 @@ +package resolvers + +import ( + i18n "example.com/no-js-e2e/i18nprefixalwaysapp/web/generated/i18n" + "example.com/no-js-e2e/i18nprefixalwaysapp/web/view" + "github.com/RevoTale/no-js/framework" + "github.com/RevoTale/no-js/framework/metagen" +) + +func (Resolver) MetaGenRootNotFound( + meta framework.MetaContext[*view.Context], + _ framework.NotFoundContext, + _ RootParams, +) (metagen.Metadata, error) { + tr := meta.App().I18n(meta.Request()) + return metagen.Metadata{Title: i18n.TUiNotFoundTitle(tr)}, nil +} + +func (Resolver) MetaGenGroupSupportHelpNotFound( + meta framework.MetaContext[*view.Context], + _ framework.NotFoundContext, + _ GroupSupportHelpParams, +) (metagen.Metadata, error) { + tr := meta.App().I18n(meta.Request()) + return metagen.Metadata{Title: i18n.TUiNotFoundTitle(tr)}, nil +} diff --git a/e2e/testdata/methodmatrixapp/web/resolvers/not_found_metadata.go b/e2e/testdata/methodmatrixapp/web/resolvers/not_found_metadata.go new file mode 100644 index 0000000..45a7503 --- /dev/null +++ b/e2e/testdata/methodmatrixapp/web/resolvers/not_found_metadata.go @@ -0,0 +1,15 @@ +package resolvers + +import ( + "example.com/no-js-e2e/methodmatrixapp/web/view" + "github.com/RevoTale/no-js/framework" + "github.com/RevoTale/no-js/framework/metagen" +) + +func (Resolver) MetaGenRootNotFound( + _ framework.MetaContext[*view.Context], + _ framework.NotFoundContext, + _ RootParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Not Found"}, nil +} diff --git a/e2e/testdata/namespacedtemplcssapp/web/resolvers/not_found_metadata.go b/e2e/testdata/namespacedtemplcssapp/web/resolvers/not_found_metadata.go new file mode 100644 index 0000000..29a4bd4 --- /dev/null +++ b/e2e/testdata/namespacedtemplcssapp/web/resolvers/not_found_metadata.go @@ -0,0 +1,15 @@ +package resolvers + +import ( + "example.com/no-js-e2e/namespacedtemplcssapp/web/view" + "github.com/RevoTale/no-js/framework" + "github.com/RevoTale/no-js/framework/metagen" +) + +func (Resolver) MetaGenRootNotFound( + _ framework.MetaContext[*view.Context], + _ framework.NotFoundContext, + _ RootParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Not Found"}, nil +} diff --git a/e2e/testdata/notfoundmetadataapp/cmd/server/main.go b/e2e/testdata/notfoundmetadataapp/cmd/server/main.go new file mode 100644 index 0000000..cd822e9 --- /dev/null +++ b/e2e/testdata/notfoundmetadataapp/cmd/server/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + gen "example.com/no-js-e2e/notfoundmetadataapp/web/generated" + "example.com/no-js-e2e/notfoundmetadataapp/web/view" + "github.com/RevoTale/no-js/framework/httpserver" +) + +func main() { + log.SetOutput(os.Stderr) + log.SetFlags(0) + + addr := flag.String("addr", "127.0.0.1:8080", "listen address") + flag.Parse() + + appContext := &view.Context{} + bundle := gen.Bundle(appContext) + + handler, err := httpserver.NewApp(httpserver.Config[*view.Context]{ + App: bundle, + }) + if err != nil { + log.Fatal(err) + } + + listener, err := net.Listen("tcp", *addr) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("LISTEN_URL=http://%s\n", listener.Addr().String()) + + server := &http.Server{ + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + if serveErr := server.Serve(listener); serveErr != nil && serveErr != http.ErrServerClosed { + log.Fatal(serveErr) + } + }() + + shutdownOnSignal(server) +} + +func shutdownOnSignal(server *http.Server) { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + <-sigCh + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + log.Print(err) + } +} diff --git a/e2e/testdata/notfoundmetadataapp/go.mod b/e2e/testdata/notfoundmetadataapp/go.mod new file mode 100644 index 0000000..07f647b --- /dev/null +++ b/e2e/testdata/notfoundmetadataapp/go.mod @@ -0,0 +1,26 @@ +module example.com/no-js-e2e/notfoundmetadataapp + +go 1.25.0 + +require ( + github.com/RevoTale/no-js v0.0.0 + github.com/a-h/templ v0.3.1001 +) + +require ( + github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect + github.com/evanw/esbuild v0.28.0 // indirect + github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect + github.com/tdewolff/parse/v2 v2.8.12 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +tool ( + github.com/RevoTale/no-js/cmd/no-js + github.com/RevoTale/no-js/cmd/templgen +) + +replace github.com/RevoTale/no-js => ../../.. diff --git a/e2e/testdata/notfoundmetadataapp/go.sum b/e2e/testdata/notfoundmetadataapp/go.sum new file mode 100644 index 0000000..c7a021c --- /dev/null +++ b/e2e/testdata/notfoundmetadataapp/go.sum @@ -0,0 +1,58 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo= +github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ= +github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY= +github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo= +github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNQzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +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/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= +github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= +github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= +github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tdewolff/parse/v2 v2.8.12 h1:5BBjfaCv482v3nltlS0u6wH1xJaxjR6ofDrWttNvROg= +github.com/tdewolff/parse/v2 v2.8.12/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo= +github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9Fp2L6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/e2e/testdata/notfoundmetadataapp/web/resolvers/root.go b/e2e/testdata/notfoundmetadataapp/web/resolvers/root.go new file mode 100644 index 0000000..da08a18 --- /dev/null +++ b/e2e/testdata/notfoundmetadataapp/web/resolvers/root.go @@ -0,0 +1,118 @@ +package resolvers + +import ( + "context" + "net/http" + + "example.com/no-js-e2e/notfoundmetadataapp/web/view" + "github.com/RevoTale/no-js/framework" + "github.com/RevoTale/no-js/framework/metagen" +) + +func (Resolver) MetaGenRootLayout(meta framework.MetaContext[*view.Context]) (metagen.Metadata, error) { + return metagen.Metadata{Description: "Root layout metadata"}, nil +} + +func (Resolver) MetaGenRootPage( + meta framework.MetaContext[*view.Context], + params RootParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Not Found Metadata Home"}, nil +} + +func (Resolver) ResolveRootPage( + ctx context.Context, + appCtx *view.Context, + r *http.Request, + params RootParams, +) (view.PageView, error) { + return view.PageView{Heading: "Not Found Metadata Home"}, nil +} + +func (Resolver) MetaGenRootNotFound( + meta framework.MetaContext[*view.Context], + notFound framework.NotFoundContext, + params RootParams, +) (metagen.Metadata, error) { + return metagen.Metadata{ + Title: "Root 404 Metadata Title", + Description: "Root 404 metadata description", + OpenGraph: &metagen.OpenGraph{Type: "website"}, + }, nil +} + +func (Resolver) ResolveRootNotFound( + ctx context.Context, + appCtx *view.Context, + r *http.Request, + notFound framework.NotFoundContext, + params RootParams, +) (view.NotFoundView, error) { + return view.NotFoundView{Heading: "Root missing"}, nil +} + +func (Resolver) MetaGenDocsLayout( + meta framework.MetaContext[*view.Context], + params DocsParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Description: "Docs layout metadata"}, nil +} + +func (Resolver) ResolveDocsLayout( + ctx context.Context, + appCtx *view.Context, + r *http.Request, + params DocsParams, +) (view.DocsLayoutView, error) { + return view.DocsLayoutView{Section: "docs"}, nil +} + +func (Resolver) MetaGenDocsPage( + meta framework.MetaContext[*view.Context], + params DocsParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Docs Index"}, nil +} + +func (Resolver) ResolveDocsPage( + ctx context.Context, + appCtx *view.Context, + r *http.Request, + params DocsParams, +) (view.PageView, error) { + return view.PageView{Heading: "Docs Index"}, nil +} + +func (Resolver) MetaGenDocsFailPage( + meta framework.MetaContext[*view.Context], + params DocsFailParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Docs Fail"}, nil +} + +func (Resolver) ResolveDocsFailPage( + ctx context.Context, + appCtx *view.Context, + r *http.Request, + params DocsFailParams, +) (view.PageView, error) { + return view.PageView{}, framework.ErrNotFound +} + +func (Resolver) MetaGenDocsNotFound( + meta framework.MetaContext[*view.Context], + notFound framework.NotFoundContext, + params DocsParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Docs 404 Metadata Title"}, nil +} + +func (Resolver) ResolveDocsNotFound( + ctx context.Context, + appCtx *view.Context, + r *http.Request, + notFound framework.NotFoundContext, + params DocsParams, +) (view.NotFoundView, error) { + return view.NotFoundView{Heading: "Docs missing"}, nil +} diff --git a/e2e/testdata/notfoundmetadataapp/web/routes/404.templ b/e2e/testdata/notfoundmetadataapp/web/routes/404.templ new file mode 100644 index 0000000..6533d73 --- /dev/null +++ b/e2e/testdata/notfoundmetadataapp/web/routes/404.templ @@ -0,0 +1,9 @@ +package routes + +import "example.com/no-js-e2e/notfoundmetadataapp/web/view" + +templ NotFound(model view.NotFoundView, path string) { + <main id="root-not-found" data-path={ path }> + { model.Heading } { path } + </main> +} diff --git a/e2e/testdata/notfoundmetadataapp/web/routes/docs/404.templ b/e2e/testdata/notfoundmetadataapp/web/routes/docs/404.templ new file mode 100644 index 0000000..dba4e5d --- /dev/null +++ b/e2e/testdata/notfoundmetadataapp/web/routes/docs/404.templ @@ -0,0 +1,9 @@ +package docs + +import "example.com/no-js-e2e/notfoundmetadataapp/web/view" + +templ NotFound(model view.NotFoundView, path string) { + <main id="docs-not-found" data-path={ path }> + { model.Heading } { path } + </main> +} diff --git a/e2e/testdata/notfoundmetadataapp/web/routes/docs/fail/page.templ b/e2e/testdata/notfoundmetadataapp/web/routes/docs/fail/page.templ new file mode 100644 index 0000000..6dffd69 --- /dev/null +++ b/e2e/testdata/notfoundmetadataapp/web/routes/docs/fail/page.templ @@ -0,0 +1,7 @@ +package fail + +import "example.com/no-js-e2e/notfoundmetadataapp/web/view" + +templ Page(model view.PageView) { + <main id="docs-fail-page">{ model.Heading }</main> +} diff --git a/e2e/testdata/notfoundmetadataapp/web/routes/docs/layout.templ b/e2e/testdata/notfoundmetadataapp/web/routes/docs/layout.templ new file mode 100644 index 0000000..6ac8330 --- /dev/null +++ b/e2e/testdata/notfoundmetadataapp/web/routes/docs/layout.templ @@ -0,0 +1,9 @@ +package docs + +import "example.com/no-js-e2e/notfoundmetadataapp/web/view" + +templ Layout(model view.DocsLayoutView, child templ.Component) { + <section data-layout={ model.Section }> + @child + </section> +} diff --git a/e2e/testdata/notfoundmetadataapp/web/routes/docs/page.templ b/e2e/testdata/notfoundmetadataapp/web/routes/docs/page.templ new file mode 100644 index 0000000..4772b96 --- /dev/null +++ b/e2e/testdata/notfoundmetadataapp/web/routes/docs/page.templ @@ -0,0 +1,7 @@ +package docs + +import "example.com/no-js-e2e/notfoundmetadataapp/web/view" + +templ Page(model view.PageView) { + <main id="docs-page">{ model.Heading }</main> +} diff --git a/e2e/testdata/notfoundmetadataapp/web/routes/page.templ b/e2e/testdata/notfoundmetadataapp/web/routes/page.templ new file mode 100644 index 0000000..be43520 --- /dev/null +++ b/e2e/testdata/notfoundmetadataapp/web/routes/page.templ @@ -0,0 +1,7 @@ +package routes + +import "example.com/no-js-e2e/notfoundmetadataapp/web/view" + +templ Page(model view.PageView) { + <main id="home-page">{ model.Heading }</main> +} diff --git a/e2e/testdata/notfoundmetadataapp/web/routes/root.templ b/e2e/testdata/notfoundmetadataapp/web/routes/root.templ new file mode 100644 index 0000000..5f2abd8 --- /dev/null +++ b/e2e/testdata/notfoundmetadataapp/web/routes/root.templ @@ -0,0 +1,14 @@ +package routes + +import "github.com/RevoTale/no-js/framework/metagen" + +templ RootLayout(meta metagen.Metadata, locale string, child templ.Component) { + <html lang={ locale }> + <head> + @metagen.Head(meta) + </head> + <body id="root-layout"> + @child + </body> + </html> +} diff --git a/e2e/testdata/notfoundmetadataapp/web/view/context.go b/e2e/testdata/notfoundmetadataapp/web/view/context.go new file mode 100644 index 0000000..bc47027 --- /dev/null +++ b/e2e/testdata/notfoundmetadataapp/web/view/context.go @@ -0,0 +1,13 @@ +package view + +import ( + "net/http" + "net/url" +) + +type Context struct{} + +func (c *Context) ResolveRoot(*http.Request) *url.URL { + root, _ := url.Parse("https://not-found.example.test") + return root +} diff --git a/e2e/testdata/notfoundmetadataapp/web/view/view_models.go b/e2e/testdata/notfoundmetadataapp/web/view/view_models.go new file mode 100644 index 0000000..ad46305 --- /dev/null +++ b/e2e/testdata/notfoundmetadataapp/web/view/view_models.go @@ -0,0 +1,13 @@ +package view + +type PageView struct { + Heading string +} + +type DocsLayoutView struct { + Section string +} + +type NotFoundView struct { + Heading string +} diff --git a/e2e/testdata/optionalcatchallapp/web/resolvers/not_found_metadata.go b/e2e/testdata/optionalcatchallapp/web/resolvers/not_found_metadata.go new file mode 100644 index 0000000..bb9e042 --- /dev/null +++ b/e2e/testdata/optionalcatchallapp/web/resolvers/not_found_metadata.go @@ -0,0 +1,15 @@ +package resolvers + +import ( + "example.com/no-js-e2e/optionalcatchallapp/web/view" + "github.com/RevoTale/no-js/framework" + "github.com/RevoTale/no-js/framework/metagen" +) + +func (Resolver) MetaGenRootNotFound( + _ framework.MetaContext[*view.Context], + _ framework.NotFoundContext, + _ RootParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Not Found"}, nil +} diff --git a/e2e/testdata/routepagecssapp/web/resolvers/not_found_metadata.go b/e2e/testdata/routepagecssapp/web/resolvers/not_found_metadata.go new file mode 100644 index 0000000..cbd0d8d --- /dev/null +++ b/e2e/testdata/routepagecssapp/web/resolvers/not_found_metadata.go @@ -0,0 +1,15 @@ +package resolvers + +import ( + "example.com/no-js-e2e/routepagecssapp/web/view" + "github.com/RevoTale/no-js/framework" + "github.com/RevoTale/no-js/framework/metagen" +) + +func (Resolver) MetaGenRootNotFound( + _ framework.MetaContext[*view.Context], + _ framework.NotFoundContext, + _ RootParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Not Found"}, nil +} diff --git a/e2e/testdata/templcssapp/web/resolvers/not_found_metadata.go b/e2e/testdata/templcssapp/web/resolvers/not_found_metadata.go new file mode 100644 index 0000000..5af4096 --- /dev/null +++ b/e2e/testdata/templcssapp/web/resolvers/not_found_metadata.go @@ -0,0 +1,15 @@ +package resolvers + +import ( + "example.com/no-js-e2e/templcssapp/web/view" + "github.com/RevoTale/no-js/framework" + "github.com/RevoTale/no-js/framework/metagen" +) + +func (Resolver) MetaGenRootNotFound( + _ framework.MetaContext[*view.Context], + _ framework.NotFoundContext, + _ RootParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Not Found"}, nil +} diff --git a/e2e/testdata/templrulesapp/web/resolvers/not_found_metadata.go b/e2e/testdata/templrulesapp/web/resolvers/not_found_metadata.go new file mode 100644 index 0000000..ac885f1 --- /dev/null +++ b/e2e/testdata/templrulesapp/web/resolvers/not_found_metadata.go @@ -0,0 +1,15 @@ +package resolvers + +import ( + "example.com/no-js-e2e/templrulesapp/web/view" + "github.com/RevoTale/no-js/framework" + "github.com/RevoTale/no-js/framework/metagen" +) + +func (Resolver) MetaGenRootNotFound( + _ framework.MetaContext[*view.Context], + _ framework.NotFoundContext, + _ RootParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Not Found"}, nil +} diff --git a/e2e/testdata/typedmodelsapp/web/resolvers/not_found_metadata.go b/e2e/testdata/typedmodelsapp/web/resolvers/not_found_metadata.go new file mode 100644 index 0000000..ea2a93d --- /dev/null +++ b/e2e/testdata/typedmodelsapp/web/resolvers/not_found_metadata.go @@ -0,0 +1,15 @@ +package resolvers + +import ( + "example.com/no-js-e2e/typedmodelsapp/web/view" + "github.com/RevoTale/no-js/framework" + "github.com/RevoTale/no-js/framework/metagen" +) + +func (Resolver) MetaGenRootNotFound( + _ framework.MetaContext[*view.Context], + _ framework.NotFoundContext, + _ RootParams, +) (metagen.Metadata, error) { + return metagen.Metadata{Title: "Typed Not Found"}, nil +} diff --git a/framework/httpserver/httpserver.go b/framework/httpserver/httpserver.go index a3e811b..84d9821 100644 --- a/framework/httpserver/httpserver.go +++ b/framework/httpserver/httpserver.go @@ -82,7 +82,16 @@ type Config[C any] struct { CachePolicies CachePolicies - NotFoundPage func(appCtx C, r *http.Request, notFoundContext framework.NotFoundContext) (templ.Component, error) + NotFoundPage func( + appCtx C, + r *http.Request, + notFoundContext framework.NotFoundContext, + ) (templ.Component, error) + NotFoundPageWithRuntime func( + runtime framework.RuntimeContext[C], + r *http.Request, + notFoundContext framework.NotFoundContext, + ) (templ.Component, error) ServerErrorPage func(err error) templ.Component LogServerError func(err error) LogServerErrorEvent func(event ServerErrorEvent) @@ -95,8 +104,17 @@ type Config[C any] struct { } type server[C any] struct { - cachePolicies CachePolicies - notFoundPage func(appCtx C, r *http.Request, notFoundContext framework.NotFoundContext) (templ.Component, error) + cachePolicies CachePolicies + notFoundPage func( + appCtx C, + r *http.Request, + notFoundContext framework.NotFoundContext, + ) (templ.Component, error) + notFoundPageWithRuntime func( + runtime framework.RuntimeContext[C], + r *http.Request, + notFoundContext framework.NotFoundContext, + ) (templ.Component, error) serverErrorPage func(err error) templ.Component appContext C logServerErr func(err error) @@ -122,16 +140,17 @@ func New[C any](cfg Config[C]) (http.Handler, error) { } srv := &server[C]{ - cachePolicies: cachePolicies, - appContext: cfg.AppContext, - notFoundPage: cfg.NotFoundPage, - serverErrorPage: cfg.ServerErrorPage, - logServerErr: cfg.LogServerError, - logServerErrEvent: cfg.LogServerErrorEvent, - logResolverTimingFn: cfg.LogResolverTiming, - enableResolverDebug: cfg.EnableResolverDebug, - healthPath: healthPath, - healthBody: healthBody, + cachePolicies: cachePolicies, + appContext: cfg.AppContext, + notFoundPage: cfg.NotFoundPage, + notFoundPageWithRuntime: cfg.NotFoundPageWithRuntime, + serverErrorPage: cfg.ServerErrorPage, + logServerErr: cfg.LogServerError, + logServerErrEvent: cfg.LogServerErrorEvent, + logResolverTimingFn: cfg.LogResolverTiming, + enableResolverDebug: cfg.EnableResolverDebug, + healthPath: healthPath, + healthBody: healthBody, } if cfg.I18n != nil { @@ -462,13 +481,13 @@ func (s *server[C]) handleNotFound( r *http.Request, notFoundContext framework.NotFoundContext, ) { - if s.notFoundPage == nil { + if s.notFoundPage == nil && s.notFoundPageWithRuntime == nil { setCachePolicy(w, s.cachePolicies.Error) http.NotFound(w, r) return } - component, err := s.notFoundPage(s.appContext, r, notFoundContext) + component, err := s.resolveNotFoundPage(r, notFoundContext) if err != nil { s.handleServerError(w, r, fmt.Errorf("resolve not found page: %w", err)) return @@ -483,6 +502,16 @@ func (s *server[C]) handleNotFound( } } +func (s *server[C]) resolveNotFoundPage( + r *http.Request, + notFoundContext framework.NotFoundContext, +) (templ.Component, error) { + if s.notFoundPageWithRuntime != nil { + return s.notFoundPageWithRuntime(s.routeEngine, r, notFoundContext) + } + return s.notFoundPage(s.appContext, r, notFoundContext) +} + func (s *server[C]) handleServerError(w http.ResponseWriter, r *http.Request, err error) { if s.serverErrorPage != nil { component := s.serverErrorPage(err) diff --git a/framework/httpserver/newapp.go b/framework/httpserver/newapp.go index d76a3d7..e03c4b6 100644 --- a/framework/httpserver/newapp.go +++ b/framework/httpserver/newapp.go @@ -31,6 +31,11 @@ type AppBundle[C any] struct { r *http.Request, notFoundContext framework.NotFoundContext, ) (templ.Component, error) + NotFoundPageWithRuntime func( + runtime framework.RuntimeContext[C], + r *http.Request, + notFoundContext framework.NotFoundContext, + ) (templ.Component, error) TemplCSSClasses func() []templ.CSSClass OnStaticAssetBasePathResolved func(prefix string) } @@ -100,27 +105,28 @@ func NewApp[C any](cfg Config[C]) (http.Handler, error) { } return New(Config[C]{ - AppContext: app.Context, - ExactHandlers: app.ExactHandlers, - Handlers: app.Handlers, - Discovery: app.Discovery, - I18n: app.I18n, - ResolveRoot: app.ResolveRoot, - PublicFiles: publicFiles, - MountExtraRoutes: custom.ExtraRoutes, - MainMiddlewares: custom.MainMiddlewares, - Static: staticMount, - TemplCSS: templCSSCfg, - CachePolicies: custom.CachePolicies, - NotFoundPage: app.NotFoundPage, - ServerErrorPage: custom.ServerErrorPage, - LogServerError: custom.LogServerError, - LogServerErrorEvent: custom.LogServerErrorEvent, - LogResolverTiming: custom.LogResolverTiming, - EnableResolverDebug: custom.EnableResolverDebug, - DisableHealth: custom.DisableHealth, - HealthPath: custom.HealthPath, - HealthBody: custom.HealthBody, + AppContext: app.Context, + ExactHandlers: app.ExactHandlers, + Handlers: app.Handlers, + Discovery: app.Discovery, + I18n: app.I18n, + ResolveRoot: app.ResolveRoot, + PublicFiles: publicFiles, + MountExtraRoutes: custom.ExtraRoutes, + MainMiddlewares: custom.MainMiddlewares, + Static: staticMount, + TemplCSS: templCSSCfg, + CachePolicies: custom.CachePolicies, + NotFoundPage: app.NotFoundPage, + NotFoundPageWithRuntime: app.NotFoundPageWithRuntime, + ServerErrorPage: custom.ServerErrorPage, + LogServerError: custom.LogServerError, + LogServerErrorEvent: custom.LogServerErrorEvent, + LogResolverTiming: custom.LogResolverTiming, + EnableResolverDebug: custom.EnableResolverDebug, + DisableHealth: custom.DisableHealth, + HealthPath: custom.HealthPath, + HealthBody: custom.HealthBody, }) } diff --git a/internal/bundler/approutegen/bundle_generation_test.go b/internal/bundler/approutegen/bundle_generation_test.go index ee02b2d..7498ff7 100644 --- a/internal/bundler/approutegen/bundle_generation_test.go +++ b/internal/bundler/approutegen/bundle_generation_test.go @@ -27,7 +27,7 @@ func TestGenerateBundleSourceWiresTemplCSSRegistryWhenEnabled(t *testing.T) { require.Contains(t, text, "func Bundle(appContext *view.Context) httpserver.AppBundle[*view.Context]") require.Contains(t, text, "resolvers := NewRouteResolvers()") require.Contains(t, text, "Handlers: Handlers(resolvers),") - require.Contains(t, text, "NotFoundPage: NotFoundPage(resolvers),") + require.Contains(t, text, "NotFoundPageWithRuntime: NotFoundPage(resolvers),") require.Contains(t, text, "TemplCSSClasses: TemplCSSClasses,") require.Contains(t, text, "OnStaticAssetBasePathResolved: nil,") } @@ -179,6 +179,7 @@ func TestRegistryGenerationUsesNotFoundResolverModels(t *testing.T) { require.NoError(t, err) text := string(registry) + require.Contains(t, text, "notFoundMeta, err := resolvers.MetaGenRootNotFound") require.Contains(t, text, "view, err := resolvers.ResolveRootNotFound") require.Contains(t, text, "component := r_not_found_root.NotFound(view, pathValue)") require.NotContains(t, text, "resolveNotFoundView") diff --git a/internal/bundler/approutegen/not_found_gen.go b/internal/bundler/approutegen/not_found_gen.go index e9f92b9..c03571f 100644 --- a/internal/bundler/approutegen/not_found_gen.go +++ b/internal/bundler/approutegen/not_found_gen.go @@ -37,7 +37,8 @@ func writeNotFoundPageFunc( }) buffer.WriteString( - "func renderNotFoundPage(resolvers RouteResolvers, appCtx *view.Context, r *http.Request, " + + "func renderNotFoundPage(resolvers RouteResolvers, runtime framework.RuntimeContext[*view.Context], " + + "r *http.Request, " + "notFound framework.NotFoundContext) (templ.Component, error) {\n", ) buffer.WriteString("\tpathValue := strings.TrimSpace(notFound.RequestPath)\n") @@ -46,17 +47,7 @@ func writeNotFoundPageFunc( buffer.WriteString("\t}\n") buffer.WriteString("\trouteID := nearestNotFoundRouteID(notFound)\n") buffer.WriteString("\tr = withNotFoundRequestInfo(r, notFound)\n") - buffer.WriteString("\tmeta := metagen.Metadata{\n") - buffer.WriteString("\t\tRobots: &metagen.Robots{\n") - buffer.WriteString("\t\t\tIndex: metagen.Bool(false),\n") - buffer.WriteString("\t\t\tFollow: metagen.Bool(false),\n") - buffer.WriteString("\t\t},\n") - buffer.WriteString("\t}\n") - buffer.WriteString("\tmeta = metagen.MergeManagedStylesheets(requestContext(r), meta)\n") - buffer.WriteString( - "\tmeta = metagen.MergeManagedClientAssets(requestContext(r), meta, " + - "notFoundClientAssets(routeID))\n", - ) + buffer.WriteString("\tappCtx := runtime.AppContext()\n") buffer.WriteString("\tswitch routeID {\n") for _, routeID := range notFoundKeys { if routeID == "" { @@ -69,6 +60,18 @@ func writeNotFoundPageFunc( } writef(buffer, "\tcase %q:\n", routeID) writeNotFoundParams(buffer, "\t\t", contract) + writef( + buffer, + "\t\tmeta, err := %s(resolvers, runtime, r, notFound, params)\n", + notFoundMetadataFuncName(notFound), + ) + buffer.WriteString("\t\tif err != nil {\n") + buffer.WriteString("\t\t\treturn nil, err\n") + buffer.WriteString("\t\t}\n") + buffer.WriteString( + "\t\tmeta = finalizeNotFoundMetadata(requestContext(r), meta, " + + "notFoundClientAssets(routeID))\n", + ) writef( buffer, "\t\tview, err := resolvers.%s(requestContext(r), appCtx, r, notFound, params)\n", @@ -114,6 +117,18 @@ func writeNotFoundPageFunc( } buffer.WriteString("\tdefault:\n") writeNotFoundParams(buffer, "\t\t", rootContract) + writef( + buffer, + "\t\tmeta, err := %s(resolvers, runtime, r, notFound, params)\n", + notFoundMetadataFuncName(rootNotFound), + ) + buffer.WriteString("\t\tif err != nil {\n") + buffer.WriteString("\t\t\treturn nil, err\n") + buffer.WriteString("\t\t}\n") + buffer.WriteString( + "\t\tmeta = finalizeNotFoundMetadata(requestContext(r), meta, " + + "notFoundClientAssets(routeID))\n", + ) writef( buffer, "\t\tview, err := resolvers.%s(requestContext(r), appCtx, r, notFound, params)\n", @@ -153,6 +168,17 @@ func writeNotFoundPageFunc( buffer.WriteString("\t}\n") buffer.WriteString("}\n\n") + for _, routeID := range notFoundKeys { + notFound := notFounds[routeID] + contract, ok := contractsByID[routeID] + if !ok { + return fmt.Errorf("missing route contract for not-found route %q", routeID) + } + if err := writeNotFoundMetadataFunc(buffer, layouts, notFound, contract, contractsByID); err != nil { + return err + } + } + writeFinalizeNotFoundMetadataFunc(buffer) writeClientAssetsSwitchFunc(buffer, "notFoundClientAssets", notFoundAssets) buffer.WriteString("func nearestNotFoundRouteID(notFound framework.NotFoundContext) string {\n") @@ -340,6 +366,97 @@ func writeNotFoundParams(buffer *bytes.Buffer, indent string, contract routeCont writef(buffer, "%sparams, _ := %s(notFoundStrippedPath(notFound))\n", indent, parseParamsFuncNameForContract(contract)) } +func notFoundMetadataFuncName(notFound templateDef) string { + return "resolve" + routeNameFromSegments(notFound.Segments) + "NotFoundMetadata" +} + +func writeNotFoundMetadataFunc( + buffer *bytes.Buffer, + layouts map[string]templateDef, + notFound templateDef, + contract routeContractDef, + contractsByID map[string]routeContractDef, +) error { + writef( + buffer, + "func %s(resolvers RouteResolvers, runtime framework.RuntimeContext[*view.Context], "+ + "r *http.Request, notFound framework.NotFoundContext, params route_resolvers.%s) "+ + "(metagen.Metadata, error) {\n", + notFoundMetadataFuncName(notFound), + contract.ParamsTypeName, + ) + buffer.WriteString( + "\tmetaCtx := framework.NewMetaContext(requestContext(r), runtime.AppContext(), " + + "r, runtime.ResolveRoot(r), runtime.I18n())\n", + ) + buffer.WriteString("\trootMeta, err := resolvers.MetaGenRootLayout(metaCtx)\n") + buffer.WriteString("\tif err != nil {\n") + buffer.WriteString("\t\treturn metagen.Metadata{}, err\n") + buffer.WriteString("\t}\n") + buffer.WriteString("\tmetaLayers := []metagen.Metadata{rootMeta}\n") + + for _, layout := range layoutChain(notFound.RouteID, layouts) { + if layout.RouteID == "" { + continue + } + layoutContract, ok := contractsByID[layout.RouteID] + if !ok { + return fmt.Errorf("missing route contract for layout route %q", layout.RouteID) + } + paramsVar := layoutParamsVarName(layout) + if err := writeParamsAssignment( + buffer, + "\t", + paramsVar, + layoutContract.ParamsTypeName, + "params", + contract.Params, + layoutContract.Params, + ); err != nil { + return fmt.Errorf("not-found route %q layout %q metadata params: %w", notFound.RouteID, layout.RouteID, err) + } + metaVar := layoutMetaVarName(layout) + writef(buffer, "\t%s, err := resolvers.%s(metaCtx, %s)\n", metaVar, metaGenLayoutMethod(layout), paramsVar) + buffer.WriteString("\tif err != nil {\n") + buffer.WriteString("\t\treturn metagen.Metadata{}, err\n") + buffer.WriteString("\t}\n") + writef(buffer, "\tmetaLayers = append(metaLayers, %s)\n", metaVar) + } + + writef( + buffer, + "\tnotFoundMeta, err := resolvers.%s(metaCtx, notFound, params)\n", + metaGenNotFoundMethod(notFound), + ) + buffer.WriteString("\tif err != nil {\n") + buffer.WriteString("\t\treturn metagen.Metadata{}, err\n") + buffer.WriteString("\t}\n") + buffer.WriteString("\tmetaLayers = append(metaLayers, notFoundMeta)\n") + buffer.WriteString("\treturn metagen.MergeAll(metaLayers...), nil\n") + buffer.WriteString("}\n\n") + return nil +} + +func layoutMetaVarName(layout templateDef) string { + return strings.ToLower(routeNameFromSegments(layout.Segments)) + "LayoutMeta" +} + +func writeFinalizeNotFoundMetadataFunc(buffer *bytes.Buffer) { + buffer.WriteString( + "func finalizeNotFoundMetadata(ctx context.Context, meta metagen.Metadata, " + + "assets metagen.ClientAssets) metagen.Metadata {\n", + ) + buffer.WriteString("\tmeta = metagen.Merge(meta, metagen.Metadata{\n") + buffer.WriteString("\t\tRobots: &metagen.Robots{\n") + buffer.WriteString("\t\t\tIndex: metagen.Bool(false),\n") + buffer.WriteString("\t\t\tFollow: metagen.Bool(false),\n") + buffer.WriteString("\t\t},\n") + buffer.WriteString("\t})\n") + buffer.WriteString("\tmeta = metagen.MergeManagedStylesheets(ctx, meta)\n") + buffer.WriteString("\treturn metagen.MergeManagedClientAssets(ctx, meta, assets)\n") + buffer.WriteString("}\n\n") +} + func layoutModelVarName(layout templateDef) string { return strings.ToLower(routeNameFromSegments(layout.Segments)) + "LayoutView" } diff --git a/internal/bundler/approutegen/page_gen.go b/internal/bundler/approutegen/page_gen.go index 90cdf88..e980bc4 100644 --- a/internal/bundler/approutegen/page_gen.go +++ b/internal/bundler/approutegen/page_gen.go @@ -257,6 +257,10 @@ func metaGenPageMethod(meta routeMeta) string { return "MetaGen" + meta.RouteName + "Page" } +func metaGenNotFoundMethod(notFound templateDef) string { + return "MetaGen" + routeNameFromSegments(notFound.Segments) + "NotFound" +} + func metaGenLayoutMethod(layout templateDef) string { if layout.RouteID == "" { return "MetaGenRootLayout" diff --git a/internal/bundler/approutegen/registry_gen.go b/internal/bundler/approutegen/registry_gen.go index bdaf5bd..5f0f33b 100644 --- a/internal/bundler/approutegen/registry_gen.go +++ b/internal/bundler/approutegen/registry_gen.go @@ -175,13 +175,14 @@ func generateRegistrySource( buffer.WriteString( "func NotFoundPage(resolvers RouteResolvers) " + - "func(appCtx *view.Context, r *http.Request, notFound framework.NotFoundContext) (templ.Component, error) {\n", + "func(runtime framework.RuntimeContext[*view.Context], r *http.Request, " + + "notFound framework.NotFoundContext) (templ.Component, error) {\n", ) buffer.WriteString( - " return func(appCtx *view.Context, r *http.Request, " + + " return func(runtime framework.RuntimeContext[*view.Context], r *http.Request, " + "notFound framework.NotFoundContext) (templ.Component, error) {\n", ) - buffer.WriteString(" return renderNotFoundPage(resolvers, appCtx, r, notFound)\n") + buffer.WriteString(" return renderNotFoundPage(resolvers, runtime, r, notFound)\n") buffer.WriteString(" }\n") buffer.WriteString("}\n\n") @@ -442,7 +443,7 @@ func generateBundleSource(paths projectlayout.ProjectLayout, viewHooks viewcontr buffer.WriteString("\t\tDiscovery: DiscoveryBundle(),\n") buffer.WriteString("\t\tI18n: i18nConfig,\n") buffer.WriteString("\t\tResolveRoot: appContext.ResolveRoot,\n") - buffer.WriteString("\t\tNotFoundPage: NotFoundPage(resolvers),\n") + buffer.WriteString("\t\tNotFoundPageWithRuntime: NotFoundPage(resolvers),\n") if paths.Assets.TemplCSS { buffer.WriteString("\t\tTemplCSSClasses: TemplCSSClasses,\n") } else { diff --git a/internal/bundler/approutegen/registry_generation_test.go b/internal/bundler/approutegen/registry_generation_test.go index 012cbc5..0ee6ab2 100644 --- a/internal/bundler/approutegen/registry_generation_test.go +++ b/internal/bundler/approutegen/registry_generation_test.go @@ -103,14 +103,15 @@ func TestRegistryGenerationUsesSingleResolverNamespace(t *testing.T) { require.Contains( t, text, - "func NotFoundPage(resolvers RouteResolvers) func(appCtx *view.Context, "+ + "func NotFoundPage(resolvers RouteResolvers) func(runtime framework.RuntimeContext[*view.Context], "+ "r *http.Request, notFound framework.NotFoundContext) (templ.Component, error)", ) require.Contains(t, text, "framework.PageOnlyRouteHandler") require.NotContains(t, text, "PageAndLiveRouteHandler") require.NotContains(t, text, "/.live/") require.NotContains(t, text, "ParseRootLiveState") - require.Contains(t, text, "return renderNotFoundPage(resolvers, appCtx, r, notFound)") + require.Contains(t, text, "return renderNotFoundPage(resolvers, runtime, r, notFound)") + require.Contains(t, text, "notFoundMeta, err := resolvers.MetaGenRootNotFound") require.Contains(t, text, "view, err := resolvers.ResolveRootNotFound") require.Contains(t, text, "RootLayout: r_root_root.RootLayout") require.Contains(t, text, "MetaGenContextChain: []framework.PageMetaGenContext") @@ -165,7 +166,8 @@ func TestRegistryGenerationEmitsClientAssets(t *testing.T) { require.Contains(t, text, "ClientAssets: metagen.ClientAssets{") require.Contains(t, text, `"routes/index.css"`) require.Contains(t, text, `"routes/index.js"`) - require.Contains(t, text, "metagen.MergeManagedClientAssets(requestContext(r), meta, notFoundClientAssets(routeID))") + require.Contains(t, text, "finalizeNotFoundMetadata(requestContext(r), meta, notFoundClientAssets(routeID))") + require.Contains(t, text, "return metagen.MergeManagedClientAssets(ctx, meta, assets)") require.Contains(t, text, "func notFoundClientAssets(routeID string) metagen.ClientAssets") require.Contains(t, text, `"routes/404.css"`) } diff --git a/internal/bundler/approutegen/resolvers_gen.go b/internal/bundler/approutegen/resolvers_gen.go index 2b382c2..d418995 100644 --- a/internal/bundler/approutegen/resolvers_gen.go +++ b/internal/bundler/approutegen/resolvers_gen.go @@ -101,6 +101,20 @@ func generateResolverNamespaceSource( fallback.ModelType, ) } + for _, routeID := range notFoundRouteIDs { + notFound := notFounds[routeID] + contract, ok := contractsByID[routeID] + if !ok { + return nil, fmt.Errorf("missing route contract for not-found route %q", routeID) + } + writef( + buffer, + "\t%s(meta framework.MetaContext[*view.Context], notFound framework.NotFoundContext, "+ + "params %s) (metagen.Metadata, error)\n", + metaGenNotFoundMethod(notFound), + contract.ParamsTypeName, + ) + } for _, routeID := range notFoundRouteIDs { notFound := notFounds[routeID] contract, ok := contractsByID[routeID] From 708d50ad17e3ab6609233da4e786f99dc8f2c735 Mon Sep 17 00:00:00 2001 From: l-you <l-you@revotale.com> Date: Tue, 28 Apr 2026 18:57:08 +0000 Subject: [PATCH 2/2] docs(app): add AI agent guide for app development Document the public no-js app mental model for agents, including generated resolver contracts, app directory ownership, Client Assets, web/assets usage, and pattern/anti-pattern examples. Link the guide from README, root AGENTS.md, app overview, and framework agent docs. AI-Impact: co-developed AI: cx --- AGENTS.md | 4 + README.md | 9 + docs/app/ai-agents.md | 474 ++++++++++++++++++++++++++++++++++++ docs/app/overview.md | 12 +- docs/framework/ai-agents.md | 3 + 5 files changed, 497 insertions(+), 5 deletions(-) create mode 100644 docs/app/ai-agents.md diff --git a/AGENTS.md b/AGENTS.md index d89dc27..e60b8dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,10 @@ If a task affects the consuming-app contract, also inspect: - `docs/app/getting-started.md` - `docs/app/conventions.md` +If you are reading this file through GitHub or MCP to build a consuming app with +`no-js`, switch to `docs/app/ai-agents.md`. This `AGENTS.md` governs work on +the framework repository itself. + ## Project Structure ```text <go-repo-root>/ diff --git a/README.md b/README.md index de262db..2bb60ad 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,14 @@ wiring is not enough. For the exact files, use [Getting Started](docs/app/getting-started.md). For the strict app contract, use [App Conventions](docs/app/conventions.md). +## AI Agent Usage + +If you are an AI agent building an application with `no-js`, start with +[AI Agents For App Development](docs/app/ai-agents.md). + +If you are modifying the `no-js` framework repository itself, use +[Framework AI Agents](docs/framework/ai-agents.md). + ## Client Assets CSS, JavaScript, and TypeScript can live beside the route or component that uses @@ -95,6 +103,7 @@ the compile/bundle split. For app developers: - [App Docs Overview](docs/app/overview.md) +- [AI Agents For App Development](docs/app/ai-agents.md) - [Getting Started](docs/app/getting-started.md) - [App Conventions](docs/app/conventions.md) - [Feature Guides](docs/app/features/overview.md) diff --git a/docs/app/ai-agents.md b/docs/app/ai-agents.md new file mode 100644 index 0000000..57b0476 --- /dev/null +++ b/docs/app/ai-agents.md @@ -0,0 +1,474 @@ +# AI Agents For App Development + +This guide is for AI agents building a consuming app with `no-js`. + +If you are changing the `no-js` framework itself, use +[Framework AI Agents](../framework/ai-agents.md) instead. + +If the consuming app has its own `AGENTS.md`, read the nearest one first. Use +this page for the `no-js`-specific app contract. + +## Mental Model + +`no-js` owns generation and runtime glue. The app owns routes, view models, +resolver implementations, services, and policy. + +The happy path is: + +```go +handler, err := httpserver.NewApp(httpserver.Config[*view.Context]{ + App: generated.Bundle(appContext), +}) +``` + +Treat generated code as a contract: + +- read `web/resolvers/generated.go` +- implement the missing resolver methods +- regenerate after changing `web/routes`, metadata, i18n, or assets +- do not edit `web/generated/*` or generated resolver contracts by hand + +## Sources Of Truth + +- app shape: [App Conventions](conventions.md) +- first runnable app: [Getting Started](getting-started.md) +- generated method names: `web/resolvers/generated.go` +- build command: `go tool no-js gen -root .` +- extra templ packages outside `web/routes`: `go tool templgen` +- asset placement: [Static Assets](features/static-assets.md) and + [Asset Pipeline Reference](reference/asset-pipeline.md) +- app workflow: the app's `Taskfile.yml` when it exists + +## Agent Loop + +1. Read the nearest app `AGENTS.md` if it exists. +2. Read `web/resolvers/generated.go` before implementing resolvers. +3. Change source files only: `web/routes`, `web/resolvers`, `web/view`, + `web/components`, `internal`, app config, or app entrypoints. +4. Run the app workflow if present: `task gen`, `task validate`, `task test`. +5. If no Taskfile exists, run `go tool no-js gen -root .`, then app tests. +6. If generation changes resolver contracts, implement the new methods instead + of editing generated files. +7. Before finishing, check that generated output is committed with the app. + +## Directory Model + +Use this shape for a new app: + +```text +your-app/ + go.mod + cmd/ + server/ + main.go + internal/ + <domain>/ + service.go + repository.go + web/ + routes/ + root.templ + page.templ + 404.templ + generated/ + components/ + card/ + card.templ + assets/ + embed.js + public/ + favicon.ico + resolvers/ + view/ + context.go + view_models.go +``` + +Meaning: + +- `cmd/server`: process entrypoint and HTTP server startup +- `internal`: app domain code, repositories, API clients, mailers, jobs, and + other code not directly about rendering +- `web/routes`: route templates, layouts, 404 templates, slots, and route-local + assets +- `web/generated`: generated output; commit it, but do not edit it by hand +- `web/resolvers`: request-to-view-model glue generated contracts ask for +- `web/view`: app context and display-specific view models +- `web/components`: reusable rendering components, one package per component +- `web/assets` and `web/public`: global hashed assets and fixed-path public + files + +Go `internal` is intentional. Packages inside `internal` can be imported only by +code under the parent tree, which makes it a good place for app-only services +that should not become rendering API. + +Use the generated `Resolver` type from `web/resolvers/generated.go`. In the +default bundle path the resolver is `route_resolvers.Resolver{}`, so app +services should usually be reachable from `*view.Context` or from types it owns. +Keep process wiring in `cmd/server`. + +## Working Rules + +Do: + +- keep templates focused on rendering and light presentation logic +- shape display data in Go before render +- use explicit view models for pages and components +- pass ordinary render data as templ parameters +- use implicit templ `ctx` only for cross-cutting request context +- put metadata in generated `MetaGen*` resolver methods +- use stable `data-*`, `id`, or `x-ref` hooks for JavaScript +- use `templ.URL(...)` for dynamic `hx-*` or other non-standard URL attributes +- keep domain entities data-focused; put repositories, mailers, and API clients + in services +- use the app's `task gen`, `task validate`, or `task test` when those tasks + exist +- run `go tool no-js gen -root .` after app shape changes +- run app tests after generation + +Do not: + +- call repositories, mailers, external APIs, or services from `.templ` files +- hide domain logic in `web/routes` +- query templ-generated CSS class names from JavaScript +- manually inject route assets that generation already manages +- add arbitrary files directly under `web/components` +- hand-edit generated files to make compilation pass +- invent resolver method signatures; copy them from `web/resolvers/generated.go` + +## Client Assets Loop + +Route and component CSS/JS are generation inputs. Add files beside the owner +that uses them, then regenerate. + +```text +web/routes/products/page.templ +web/routes/products/page.css +web/routes/products/page.ts + +web/components/card/card.templ +web/components/card/card.css +web/components/card/card.ts +``` + +Rules: + +- owner assets use the same stem as the owner template: `page.css`, + `layout.tsx`, `404.css`, `card.ts` +- each route or component owner has at most one script source extension +- imported component assets are discovered from component imports in templates +- route/component CSS imports do not define asset ownership +- TSX is bundled, but `no-js` does not configure a JSX runtime or typecheck it +- `web/assets` is for explicit global hashed files, not normal route CSS/JS +- `web/public` is for fixed-path public files + +Use `web/assets` when a file needs its own hashed URL outside the route graph: + +- embed CSS or JS consumed by another website +- global vendor CSS or JS that the app intentionally injects +- fonts, images, downloads, Open Graph images, and other addressable files + +Do not use `web/assets` for normal page, layout, 404, or component CSS/JS. Files +under `web/assets` are not auto-injected and do not get generated class +constants or script helpers. Reference them intentionally, for example from +metadata: + +```go +return metagen.Metadata{ + Stylesheets: []string{ + metagen.AssetURL(meta.Context(), "site.css"), + }, +}, nil +``` + +Run: + +```bash +go tool no-js gen -root . +``` + +That writes route helpers, Client Asset helpers, and bundled browser assets. If +the app splits the workflow, `go tool no-js gen routes -root .` writes generated +Go helpers and source-adjacent Client Asset helpers, while +`go tool no-js gen assets -root .` writes the browser CSS/JS bundles. + +## Patterns And Anti-patterns + +The examples below use generated root-route method shapes. App fields such as +`Catalog` and view-model helpers are illustrative; generated method names and +parameters are not. Each pair shows the same task with one important decision +changed. + +### Resolver Owns Data Loading + +Load service data in the resolver and pass render-ready data to the template. + +#### Pattern + +```go +func (Resolver) ResolveRootPage( + ctx context.Context, + appCtx *view.Context, + _ *http.Request, + _ RootParams, +) (view.RootPageView, error) { + products, err := appCtx.Catalog.Featured(ctx) + if err != nil { + return view.RootPageView{}, err + } + return view.RootPageView{Products: view.ProductCards(products)}, nil +} +``` + +```templ +templ Page(model view.RootPageView) { + <ul> + for _, product := range model.Products { + <li>{ product.Name }</li> + } + </ul> +} +``` + +The pattern keeps data loading and error handling in Go before render. + +#### Anti-pattern + +```templ +templ Page(app *view.Context) { + <ul> + for _, product := range app.Catalog.MustFeatured(ctx) { + <li>{ product.Name }</li> + } + </ul> +} +``` + +The anti-pattern performs service work from the template. + +### Templates Own Markup + +Templates should render a view model. They should not perform application +actions. + +#### Pattern + +```go +type CheckoutPageView struct { + Paid bool +} +``` + +```templ +templ CheckoutPage(model view.CheckoutPageView) { + <main> + if model.Paid { + <p>Paid</p> + } + </main> +} +``` + +The pattern gives the template one display decision that already came from app +code. + +#### Anti-pattern + +```templ +templ CheckoutPage(checkout *domain.Checkout) { + if checkout.Charge(ctx) { + <main>Paid</main> + } +} +``` + +The anti-pattern charges during rendering instead of rendering existing state. + +### Metadata Owns Head Data + +Set page head data in generated metadata resolvers. + +#### Pattern + +```go +func (Resolver) MetaGenRootPage( + meta framework.MetaContext[*view.Context], + _ RootParams, +) (metagen.Metadata, error) { + alternates, err := meta.Alternates(meta.Locale(), nil) + if err != nil { + return metagen.Metadata{}, err + } + return metagen.Metadata{ + Title: "Home", + Alternates: alternates, + }, nil +} +``` + +The pattern lets `@metagen.Head(meta)` compose title, canonical links, +alternates, and managed assets in the root layout. + +#### Anti-pattern + +```templ +templ Page(model view.RootPageView) { + <head><title>{ model.Title } +} +``` + +The anti-pattern puts document head data in a page template. + +### Assets Follow Route Ownership + +Colocate route assets with the route template that owns them. + +#### Pattern + +```text +web/routes/products/page.templ +web/routes/products/page.css +web/routes/products/page.ts +``` + +#### Anti-pattern + +```text +web/routes/products/page.templ +web/routes/products/styles.css +web/routes/products/product.ts +``` + +The anti-pattern uses arbitrary asset names. Generation discovers route assets +by same-stem ownership, such as `page.css` and `page.ts` beside `page.templ`. +Put helper Go packages, images, data files, and domain code outside +`web/routes`. + +### Components Are Packages + +Each component is a Go package under `web/components//`. + +#### Pattern + +```text +web/components/card/card.templ +web/components/card/card.css +web/components/card/card.ts +``` + +#### Anti-pattern + +```text +web/components/card.templ +web/components/card.css +web/components/card.ts +``` + +Component directory basename, Go package name, anchor file stem, and asset stem +must align. Files do not live directly under `web/components`. + +### Explicit Assets Are Opt-in + +Use `web/assets` for hashed files outside the generated route graph. + +#### Pattern + +```text +web/assets/embed.js +web/assets/site.css +web/assets/og/default.png +``` + +```go +func (Resolver) MetaGenRootPage( + meta framework.MetaContext[*view.Context], + _ RootParams, +) (metagen.Metadata, error) { + return metagen.Metadata{ + Stylesheets: []string{ + metagen.AssetURL(meta.Context(), "site.css"), + }, + }, nil +} +``` + +The pattern makes global or embeddable assets explicit and hashed. + +#### Anti-pattern + +```text +web/assets/products.css +web/assets/card.ts +``` + +The anti-pattern uses `web/assets` for normal route or component assets. Put +those beside `page.templ`, `layout.templ`, `404.templ`, or the component anchor +instead. + +### JavaScript Uses Stable Hooks + +Use stable `data-*`, `id`, or `x-ref` hooks for client behavior. + +#### Pattern + +```templ +css saveButton() { + display: inline-flex; +} + +templ SaveButton(label string) { + +} +``` + +```js +document.querySelector("[data-save-button]")?.addEventListener("click", save); +``` + +The pattern keeps generated CSS classes for styling and gives JavaScript a +stable hook. + +#### Anti-pattern + +```templ +css saveButton() { + display: inline-flex; +} + +templ SaveButton(label string) { + +} +``` + +```js +document.querySelector(".templ_123abc").addEventListener("click", save); +``` + +The anti-pattern queries a templ-generated class, which is not a cross-file +contract. + +## Greenfield Flow + +1. Create `go.mod`. +2. Add `github.com/RevoTale/no-js`, `github.com/a-h/templ`, and the `no-js` + Go tool. +3. Create `cmd/server`, `web/routes`, `web/resolvers`, and `web/view`. +4. Add `root.templ`, one `page.templ`, and `404.templ`. +5. Run `go tool no-js gen -root .`. +6. Implement the generated resolver methods. +7. Wire `generated.Bundle(appContext)` into `httpserver.NewApp(...)`. +8. Add app services under `internal` when the page needs real data. +9. Run `go tool no-js gen -root .` again. +10. Run tests. + +Prefer the full example in [Getting Started](getting-started.md) when creating +the first app files; this page explains agent behavior, not every file body. + +## Read Next + +1. [Getting Started](getting-started.md) +2. [App Conventions](conventions.md) +3. [Routing and Generation](features/routing-and-generation.md) +4. [Metadata and Head](features/metadata-and-head.md) +5. [Static Assets](features/static-assets.md) +6. [CLI Reference](reference/cli.md) diff --git a/docs/app/overview.md b/docs/app/overview.md index df4d99c..e967424 100644 --- a/docs/app/overview.md +++ b/docs/app/overview.md @@ -46,16 +46,18 @@ policy. 1. [Getting Started](getting-started.md) Build the smallest working app. -2. [App Conventions](conventions.md) +2. [AI Agents For App Development](ai-agents.md) + Use the app-facing workflow when an AI agent is editing a consuming app. +3. [App Conventions](conventions.md) Learn the strict `web/*` contract and current generated-code expectations. -3. [CLI Reference](reference/cli.md) +4. [CLI Reference](reference/cli.md) Use the build-time commands correctly. -4. [Bundle Config Reference](reference/bundle-config.md) +5. [Bundle Config Reference](reference/bundle-config.md) Override paths or feature auto-detection only when you need to. -5. [Feature Guides](features/overview.md) +6. [Feature Guides](features/overview.md) Go deeper on routing, runtime, metadata, i18n, discovery, assets, site resolution, and HTMX behavior. -6. [Troubleshooting](troubleshooting.md) +7. [Troubleshooting](troubleshooting.md) Fix the common generation and startup failures quickly. ## Migration Guides diff --git a/docs/framework/ai-agents.md b/docs/framework/ai-agents.md index 7883569..c581755 100644 --- a/docs/framework/ai-agents.md +++ b/docs/framework/ai-agents.md @@ -2,6 +2,9 @@ This guide is for agents modifying the `no-js` framework repository itself. +For agents building a consuming app with `no-js`, use +[AI Agents For App Development](../app/ai-agents.md). + ## Read Order When you need context, inspect in this order: