From 16c0a2cbb6eaef060c5cd49ce5fa2ef8f8c2f050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andri=20=C3=93skarsson?= Date: Thu, 2 Apr 2026 11:16:11 +0200 Subject: [PATCH] Replace HTML-like component syntax with Go template actions, add Gastro website example Migrate component invocation from / ... to {{ render Component (dict ...) }} / {{ wrap Component (dict ...) }}...{{ end }}. Replace with {{ .Children }}. This eliminates ambiguity between component invocations and HTML content, removes the need for escape directives, and aligns the template body closer to standard html/template syntax. Transformer rewritten with state-aware scanner for correct {{ end }} matching. Comment extraction prevents false regex matches inside {{/* */}} blocks. Old-syntax detection provides migration hints for {.Expr} prop syntax. Also adds examples/gastro -- an interactive documentation website showcasing the framework with landing page, 6 docs pages, live SSE demo, and Dockerfile. Fixes two pre-existing bugs: - Duplicate {{define}} names when same component-with-children used multiple times - Import extraction picking up imports inside backtick string literals --- DECISIONS.md | 4 + cmd/gastro-lsp/lsp_integration_test.go | 20 +- cmd/gastro-lsp/main.go | 15 +- docs/architecture.md | 10 +- docs/components.md | 52 +- docs/design.md | 77 +- docs/pages.md | 10 +- docs/sse.md | 6 +- examples/blog/components/layout.gastro | 2 +- examples/blog/components/post-card.gastro | 2 +- examples/blog/pages/about/index.gastro | 4 +- examples/blog/pages/blog/[slug].gastro | 4 +- examples/blog/pages/blog/index.gastro | 6 +- examples/blog/pages/index.gastro | 6 +- .../dashboard/components/dashboard.gastro | 10 +- examples/dashboard/components/layout.gastro | 2 +- examples/dashboard/pages/index.gastro | 4 +- examples/gastro/Dockerfile | 26 + examples/gastro/components/code-block.gastro | 13 + examples/gastro/components/counter.gastro | 8 + examples/gastro/components/docs-layout.gastro | 34 + examples/gastro/components/hero.gastro | 40 + examples/gastro/components/layout.gastro | 44 + examples/gastro/content/docs.go | 448 ++++++++++ examples/gastro/go.mod | 7 + examples/gastro/main.go | 46 + examples/gastro/pages/docs/components.gastro | 80 ++ examples/gastro/pages/docs/demo.gastro | 48 + examples/gastro/pages/docs/deployment.gastro | 58 ++ .../gastro/pages/docs/getting-started.gastro | 61 ++ examples/gastro/pages/docs/helpers.gastro | 83 ++ examples/gastro/pages/docs/pages.gastro | 94 ++ examples/gastro/pages/docs/sse.gastro | 88 ++ examples/gastro/pages/index.gastro | 70 ++ examples/gastro/static/logo.svg | 5 + examples/gastro/static/styles.css | 817 ++++++++++++++++++ examples/sse/components/layout.gastro | 2 +- examples/sse/pages/index.gastro | 4 +- internal/codegen/template.go | 358 +++++--- internal/codegen/template_test.go | 272 +++++- .../testdata/basic/components/layout.gastro | 2 +- .../testdata/basic/pages/index.gastro | 4 +- .../composition/components/card.gastro | 2 +- .../testdata/composition/pages/index.gastro | 2 +- internal/lsp/template/completions.go | 152 +--- internal/lsp/template/completions_test.go | 86 +- internal/lsp/template/parse.go | 6 + internal/parser/parser.go | 23 + internal/parser/parser_test.go | 95 ++ 49 files changed, 2849 insertions(+), 463 deletions(-) create mode 100644 examples/gastro/Dockerfile create mode 100644 examples/gastro/components/code-block.gastro create mode 100644 examples/gastro/components/counter.gastro create mode 100644 examples/gastro/components/docs-layout.gastro create mode 100644 examples/gastro/components/hero.gastro create mode 100644 examples/gastro/components/layout.gastro create mode 100644 examples/gastro/content/docs.go create mode 100644 examples/gastro/go.mod create mode 100644 examples/gastro/main.go create mode 100644 examples/gastro/pages/docs/components.gastro create mode 100644 examples/gastro/pages/docs/demo.gastro create mode 100644 examples/gastro/pages/docs/deployment.gastro create mode 100644 examples/gastro/pages/docs/getting-started.gastro create mode 100644 examples/gastro/pages/docs/helpers.gastro create mode 100644 examples/gastro/pages/docs/pages.gastro create mode 100644 examples/gastro/pages/docs/sse.gastro create mode 100644 examples/gastro/pages/index.gastro create mode 100644 examples/gastro/static/logo.svg create mode 100644 examples/gastro/static/styles.css diff --git a/DECISIONS.md b/DECISIONS.md index c90a9e7..40cd133 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -11,4 +11,8 @@ - **2026-04-01** (m+git@andri.dk) LSP: component auto-import and prop completions. Added `<` as completion trigger character. Components directory is scanned on startup to build an index of available components. Selecting an un-imported component from completions auto-inserts the import declaration via `additionalTextEdits`. Prop name completions offered when cursor is inside a component tag (``), with type info and filtering of already-specified props. - **2026-04-01** (m+git@andri.dk) LSP: pipe function completions in prop values. Added `|` as trigger character. When cursor is after `|` inside a prop value expression (`` / `...` / `` with Go template actions `{{ render Component (dict ...) }}` / `{{ wrap Component (dict ...) }}...{{ end }}` / `{{ .Children }}`. Eliminates ambiguity between component invocations and HTML content. Template transformer rewritten from regex-on-HTML (~250 lines) to `{{ }}` action matching (~180 lines) with a state-aware scanner for correct `{{ end }}` matching. Escape directive removed (no longer needed). Breaking change: all `.gastro` files migrated. LSP updated to detect new syntax. +- **2026-04-02** (m+git@andri.dk) Fix: unique child template names in `replaceWithChildren`. Using the same component-with-children multiple times on one page (e.g., multiple `...`) produced duplicate `{{define "layout_children"}}` blocks, rejected by Go's template parser. Fix adds an incrementing counter to produce `layout_children_0`, `layout_children_1`, etc. Counter is scoped to `replaceComponents` and passed as `*int` to `replaceWithChildren`. +- **2026-04-02** (m+git@andri.dk) Fix: backtick-aware import extraction in parser. `extractImports` and `stripImports` did line-based matching without tracking whether a line was inside a backtick raw string literal. Import statements inside Go string constants were falsely extracted as real imports, and `stripImports` silently corrupted the string content. Fix adds `inString` tracking using the existing `hasUnclosedBacktick` helper, matching the pattern already used in `splitSections`. +- **2026-04-02** (m+git@andri.dk) Template escape directive: added `{{/* gastro:escape */}}...{{/* /gastro:escape */}}` to skip component/slot transformation inside marked blocks. Uses Go template comment syntax so it's invisible at render time. Null-byte-delimited placeholders prevent collision with user content. Validates unclosed, nested, and orphaned blocks. Primary use case: documentation and code examples that show Gastro component syntax without it being processed by the transformer. - **2026-04-02** (m+git@andri.dk) Codegen template unification: evaluated and rejected. Keeping `handlerTmpl` (pages) and `componentTmpl` (components) as separate Go templates in `generate.go`. Pages stream directly to `http.ResponseWriter`; components buffer into `template.HTML`. Merging would force pages to buffer unnecessarily and add conditional complexity for ~10 lines of shared code. Fixed a real bug instead: both templates now handle `template.Execute` errors that were previously silently discarded. diff --git a/cmd/gastro-lsp/lsp_integration_test.go b/cmd/gastro-lsp/lsp_integration_test.go index fb4ad86..8ba2240 100644 --- a/cmd/gastro-lsp/lsp_integration_test.go +++ b/cmd/gastro-lsp/lsp_integration_test.go @@ -910,7 +910,7 @@ func TestLSP_ComponentPropDiagnostics(t *testing.T) { import Card "components/card.gastro" Title := "Hello" --- -` +{{ render Card (dict "Title" .Title "Bogus" "bad") }}` fileURI := "file://" + filepath.Join(projectDir, "pages", "index.gastro") client.notify("textDocument/didOpen", map[string]any{ @@ -979,9 +979,9 @@ func TestLSP_ComponentHover(t *testing.T) { client.recv(t, 10*time.Second) client.notify("initialized", map[string]any{}) - // line 3: - // ^--- 'C' is at char 1 (0-indexed), 'Card' is chars 1-4 - gastroContent := "---\nimport Card \"components/card.gastro\"\nTitle := \"Hello\"\n---\n" + // line 4: {{ render Card (dict "Title" .Title) }} + // ^--- 'C' is at char 10 (0-indexed), 'Card' is chars 10-13 + gastroContent := "---\nimport Card \"components/card.gastro\"\nTitle := \"Hello\"\n---\n{{ render Card (dict \"Title\" .Title) }}" fileURI := "file://" + filepath.Join(projectDir, "pages", "index.gastro") client.notify("textDocument/didOpen", map[string]any{ @@ -993,10 +993,10 @@ func TestLSP_ComponentHover(t *testing.T) { }, }) - // Hover on "Card" (line 4, char 2 — inside the component name) + // Hover on "Card" (line 4, char 11 — inside the component name) client.send("textDocument/hover", map[string]any{ "textDocument": map[string]any{"uri": fileURI}, - "position": map[string]any{"line": 4, "character": 2}, + "position": map[string]any{"line": 4, "character": 11}, }) resp := client.recv(t, 10*time.Second) @@ -1041,8 +1041,8 @@ func TestLSP_ComponentDefinition(t *testing.T) { client.recv(t, 10*time.Second) client.notify("initialized", map[string]any{}) - // line 4: - gastroContent := "---\nimport Card \"components/card.gastro\"\nTitle := \"Hello\"\n---\n" + // line 4: {{ render Card (dict "Title" .Title) }} + gastroContent := "---\nimport Card \"components/card.gastro\"\nTitle := \"Hello\"\n---\n{{ render Card (dict \"Title\" .Title) }}" fileURI := "file://" + filepath.Join(projectDir, "pages", "index.gastro") client.notify("textDocument/didOpen", map[string]any{ @@ -1054,10 +1054,10 @@ func TestLSP_ComponentDefinition(t *testing.T) { }, }) - // Go-to-definition on "Card" (line 4, char 2) + // Go-to-definition on "Card" (line 4, char 11) client.send("textDocument/definition", map[string]any{ "textDocument": map[string]any{"uri": fileURI}, - "position": map[string]any{"line": 4, "character": 2}, + "position": map[string]any{"line": 4, "character": 11}, }) resp := client.recv(t, 10*time.Second) diff --git a/cmd/gastro-lsp/main.go b/cmd/gastro-lsp/main.go index a4de4c6..3ce1f68 100644 --- a/cmd/gastro-lsp/main.go +++ b/cmd/gastro-lsp/main.go @@ -172,7 +172,7 @@ func (s *server) handleInitialize(msg *jsonRPCMessage) *jsonRPCMessage { "capabilities": map[string]any{ "textDocumentSync": 1, // Full sync "completionProvider": map[string]any{ - "triggerCharacters": []string{".", "<", "|"}, + "triggerCharacters": []string{".", "{", "|"}, }, "hoverProvider": true, "definitionProvider": true, @@ -885,14 +885,15 @@ func (s *server) templateHover(uri, content string, pos proxy.Position, parsed * } } -// componentTagNameRegex matches component tag names with their byte positions. -var componentTagNameRegex = regexp.MustCompile(` nameEnd { continue @@ -1030,7 +1031,7 @@ func (s *server) componentDefinition(parsed *parser.File, pos proxy.Position) an return nil } - for _, idx := range componentTagNameRegex.FindAllStringSubmatchIndex(body, -1) { + for _, idx := range componentNameRegex.FindAllStringSubmatchIndex(body, -1) { nameStart, nameEnd := idx[2], idx[3] if offset < nameStart || offset > nameEnd { continue diff --git a/docs/architecture.md b/docs/architecture.md index 53df543..a242e9a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,13 +93,13 @@ The `gastro.Props()` marker is stripped (component generation is TODO). **Key function:** `TransformTemplate(body, uses) (string, error)` Transforms the template body: -- `` becomes `{{ __gastro_ComponentName (dict "Prop" .expr) }}` -- `...` becomes a template call with children -- `` becomes `{{ .Children }}` +- `{{ render ComponentName (dict "Prop" .expr) }}` becomes `{{ __gastro_ComponentName (dict "Prop" .expr) }}` +- `{{ wrap ComponentName (dict ...) }}...{{ end }}` becomes a template call with children +- `{{ .Children }}` passes through unchanged - Standard `{{ }}` expressions pass through unchanged -Uses iterative string processing with regex for tag matching. Processes -self-closing tags first, then open/close tags with children. +Uses iterative string processing with regex for action matching. Processes +`render` actions first, then `wrap`/`end` actions with children. ### `internal/router/` diff --git a/docs/components.md b/docs/components.md index 39796cb..5572d00 100644 --- a/docs/components.md +++ b/docs/components.md @@ -2,7 +2,7 @@ Components are reusable `.gastro` files in the `components/` directory. They accept typed props, can render children via slots, and are invoked from pages -or other templates using HTML-like syntax. +or other templates using Go template actions. ## Defining a component @@ -123,38 +123,38 @@ project root. ### Invoking in templates -Components are invoked with HTML-like syntax. Self-closing for components -without children: +Components are invoked with Go template actions. Use `render` for leaf +components (no children): -```html - +``` +{{ render PostCard (dict "Title" .Title "Slug" .Slug) }} ``` -With opening and closing tags for components that accept children: +Use `wrap` for components that accept children, closed by `{{ end }}`: -```html - +``` +{{ wrap Layout (dict "Title" .Title) }}

Hello

This content goes into the slot.

-
+{{ end }} ``` ### Prop syntax -Props are passed as attributes on the component tag: +Props are passed using `dict` syntax inside the template action: | Syntax | Meaning | Example | |--------|---------|---------| -| `{.Expr}` | Go template expression, evaluated in the parent's data context | `Title={.Title}` | -| `"literal"` | String literal | `Title="About"` | -| `{.Val \| func "arg"}` | Pipe expression | `Date={.CreatedAt \| timeFormat "Jan 2, 2006"}` | +| `.Expr` | Go template expression, evaluated in the parent's data context | `"Title" .Title` | +| `"literal"` | String literal | `"Title" "About"` | +| `(.Val \| func "arg")` | Pipe expression | `"Date" (.CreatedAt \| timeFormat "Jan 2, 2006")` | -Expressions inside `{}` have access to the parent page's template data (the +Expressions have access to the parent page's template data (the uppercase variables from the parent's frontmatter). ## Slots -Slots let a component render content provided by its parent. Place `` +Slots let a component render content provided by its parent. Place `{{ .Children }}` in the component template where children should appear: ```gastro @@ -170,27 +170,27 @@ Title := gastro.Props().Title
- + {{ .Children }}
...
``` -The parent passes children by wrapping content in the component tags: +The parent passes children by using `wrap` with the component: -```html - +``` +{{ wrap Layout (dict "Title" "Home") }}

Welcome

This replaces the slot.

-
+{{ end }} ``` Children are rendered in the **parent's** data context, so they can reference -the parent's template data. The rendered HTML is then inserted where `` +the parent's template data. The rendered HTML is then inserted where `{{ .Children }}` appears in the component. -Only unnamed slots are supported. A component can have one ``. +Only unnamed slots are supported. A component can have one `{{ .Children }}`. ## Complete example @@ -218,7 +218,7 @@ Title := gastro.Props().Title Blog
- + {{ .Children }}

Built with Gastro

@@ -269,12 +269,12 @@ if err != nil { Posts := posts Title := "Home" --- - +{{ wrap Layout (dict "Title" .Title) }}

Welcome to My Blog

{{ range .Posts }} - + {{ render PostCard (dict "Slug" .Slug "Title" .Title "Author" .Author "Date" (.CreatedAt | timeFormat "Jan 2, 2006")) }} {{ end }}
-
+{{ end }} ``` diff --git a/docs/design.md b/docs/design.md index 7075956..e94d554 100644 --- a/docs/design.md +++ b/docs/design.md @@ -15,7 +15,7 @@ Think: Astro's developer experience, Go's type safety, PHP's file-based routing. |-------|--------|-------| | 1. Parser | Done | 14 tests. Splits frontmatter/body, extracts imports and component imports. | | 2. Frontmatter codegen | Done | 9 tests. Go AST analysis, variable extraction, gastro marker detection. | -| 3. Template codegen | Done | 12 tests. `` to template calls, ``, prop parsing, pipe expressions in props, child content extraction into `{{define}}` blocks. | +| 3. Template codegen | Done | 12 tests. `{{ render Component ... }}` / `{{ wrap Component ... }}` to internal template calls, prop parsing, pipe expressions in props, child content extraction into `{{define}}` blocks. | | 4. Component system | Done | 8 tests. `MapToStruct[T]` with type coercion, component render functions (`func(map[string]any) template.HTML`), per-page init with component FuncMap, `__gastro_render_children` closure for slot content. | | 5. File router | Done | 10 tests. Directory-to-route mapping, `[param]` patterns, func name derivation. | | 6. Runtime library | Done | 13 tests. Context, DefaultFuncs (18 helpers), Recover. | @@ -63,7 +63,7 @@ Think: Astro's developer experience, Go's type safety, PHP's file-based routing. |-------|-------------|----------| | 1. Parser | Frontmatter extraction, `---` delimiter handling, component import declarations, edge cases (empty frontmatter, missing delimiters, `---` inside strings) | Table-driven: input `.gastro` string -> expected frontmatter + template body | | 2. Frontmatter codegen | Import extraction (Go and component), uppercase/lowercase variable separation, struct pointer detection, generated Go code output | Table-driven: frontmatter string -> expected AST results. Golden files: frontmatter -> generated `.go` code | -| 3. Template codegen | `` transformation, `` handling, prop parsing (`{.expr}` and `"literal"`), passthrough of `{{ }}` | Table-driven: template body input -> expected transformed template output | +| 3. Template codegen | `{{ render Component ... }}` / `{{ wrap Component ... }}` transformation, prop parsing via `(dict ...)`, passthrough of `{{ }}` | Table-driven: template body input -> expected transformed template output | | 4. Component system | Props struct detection, `mapToStruct[T]()` coercion (string->bool, string->int, type mismatches), render function generation | Unit tests for `mapToStruct` with all coercion paths. Golden files for generated component code | | 5. File router | Directory walking, route table generation, `[param]` pattern mapping, `index.gastro` handling, route ordering | Table-driven: directory tree structure -> expected route table | | 6. Runtime library | `Context` methods, `DefaultFuncs()` behaviour (each built-in helper), `WithFuncs()` override semantics, `Recover` panic handling | Standard unit tests per function. Integration test: full request cycle through a generated handler | @@ -131,8 +131,8 @@ A `.gastro` file has two sections separated by `---` delimiters: **Template body rules:** - Uses standard Go `html/template` syntax (`{{ }}`). -- `` tags invoke components (imported via `import`). -- `` renders child content passed by a parent component. +- `{{ render Component ... }}` and `{{ wrap Component ... }}` invoke components (imported via `import`). +- `{{ .Children }}` renders child content passed by a parent component. - No custom expression shorthand -- `{{ }}` only. - `{{define}}` / `{{template}}` are supported but scoped to the file. @@ -210,13 +210,13 @@ Title := post.Title Body := post.Body Author := post.Author --- - +{{ wrap Layout (dict "Title" .Title) }}

{{ .Title }}

By {{ .Author }}

{{ .Body }}
-
+{{ end }} ``` ### Components (live in `components/` or anywhere) @@ -245,7 +245,7 @@ Body := p.Body

{{ .Title }}

{{ .Body }}

- + {{ .Children }}
``` @@ -307,17 +307,18 @@ import ( ### Invoking Components -Components are invoked with JSX-like syntax: +Components are invoked with Go template actions: -```html - +``` +{{ render Card (dict "Title" .post.Title "Body" .post.Summary "Urgent" .post.IsHot) }} ``` **Prop passing syntax:** -- `{.expr}` -- Go template expression evaluated in the parent's data context. +- `.expr` -- Go template expression evaluated in the parent's data context. - `"literal"` -- string literal. -- PascalCase tag name must match an imported component name. +- Component name must match an imported component name. +- Props use `(dict "Key" value ...)` syntax. **Prop type coercion:** Component props are passed via `dict` (producing `map[string]any`) and converted to the typed `Props` struct at runtime using @@ -334,10 +335,10 @@ reflection. Type coercion rules: The compiler can also emit **compile-time warnings** for obvious type mismatches by analyzing the frontmatter AST. -The compiler transforms component tags into template function calls: +The compiler transforms component template actions into internal function calls: ``` - +{{ render Card (dict "Title" .Name) }} -> {{ __gastro_Card (dict "Title" .Name) }} ``` @@ -346,7 +347,7 @@ The compiler transforms component tags into template function calls: ## 6. Slots -Components accept children via ``. Child content is pre-rendered to +Components accept children via `{{ .Children }}`. Child content is pre-rendered to `template.HTML` in the **parent's** data context, then passed to the component as a special `__children` prop. @@ -363,17 +364,17 @@ Title := gastro.Props().Title {{ .Title }} - + {{ .Children }} ``` **Caller:** -```html - +``` +{{ wrap Layout (dict "Title" .Title) }}

{{ .Greeting }}

-
+{{ end }} ``` **Implementation:** @@ -385,8 +386,8 @@ The compiler transforms this to: ``` Where `__gastro_render_children` executes a sub-template with the parent's data -context and returns rendered HTML. Inside the component, `` compiles to -`{{ .Children }}` where `Children` is `template.HTML` (safe, not escaped). +context and returns rendered HTML. Inside the component, `{{ .Children }}` +outputs the rendered HTML (`template.HTML`, safe, not escaped). **Implication:** Slot content is opaque HTML. The child component cannot inspect or manipulate it -- it can only place it. This matches Astro's behavior. @@ -600,11 +601,10 @@ func main() { | - Detect Props struct (components) or Context() call (pages) | +- 3. Analyze template body: - | - Find `` component tags - | - Parse component prop attributes ({expr} and "literal") + | - Find `{{ render Component ... }}` and `{{ wrap Component ... }}` actions + | - Parse component props from `(dict ...)` syntax | - Transform to __gastro_ComponentName template function calls - | - Find `` tags, transform to {{ .Children }} output - | - Pass through {{ }} expressions unchanged + | - Pass through {{ .Children }} and other {{ }} expressions unchanged | +- 4. Generate Go source: | - Wrap frontmatter in handler func (pages) or render func (components) @@ -722,15 +722,8 @@ document frontmatter_delimiter --- template_body -> injects tree-sitter-html template_expression {{ ... }} - component_tag - component_name - component_prop - prop_name - prop_value - self_closing_tag - component_open_tag - component_close_tag - slot_tag + component_render {{ render Component (dict ...) }} + component_wrap {{ wrap Component (dict ...) }} ... {{ end }} ``` Tree-sitter is prioritized first, providing syntax highlighting in Neovim, Zed, @@ -798,12 +791,12 @@ virtual `.go` line numbers. | `{{ .Var.Field }}` completions | Template exprs | gastro-lsp via gopls | | `{{ func }}` / pipe completions | Template exprs | gastro-lsp (FuncMap) | | Type-aware hover on `{{ }}` | Template exprs | gastro-lsp via gopls | -| Component name completions | `` | gastro-lsp | -| Component prop completions | `` | gastro-lsp | -| Component go-to-definition | `` | gastro-lsp | +| Component name completions | `{{ render/wrap }}` | gastro-lsp | +| Component prop completions | `{{ render/wrap }}` | gastro-lsp | +| Component go-to-definition | `{{ render/wrap }}` | gastro-lsp | | Unknown variable diagnostic | Template exprs | gastro-lsp | -| Unknown component diagnostic | `` | gastro-lsp | -| Missing/wrong prop diagnostic | `` | gastro-lsp | +| Unknown component diagnostic | `{{ render/wrap }}` | gastro-lsp | +| Missing/wrong prop diagnostic | `{{ render/wrap }}` | gastro-lsp | | Component import completions | Frontmatter | gastro-lsp | --- @@ -818,9 +811,9 @@ virtual `.go` line numbers. | 4 | Routing | Auto-generated file router from `pages/` | | 5 | HTTP framework | `net/http` (Go 1.22+) | | 6 | Expression syntax | `{{ }}` only, no shorthand sugar | -| 7 | Prop expression type | Go template expressions: `{.Name}` compiles to `{{ .Name }}` | +| 7 | Prop expression type | Go template expressions via `(dict "Key" .Value ...)` syntax | | 8 | Component resolution | Explicit `import` with `.gastro` paths in frontmatter | -| 9 | Slots | `` unnamed only (v1). Pre-rendered to `template.HTML` | +| 9 | Slots | `{{ .Children }}` unnamed only (v1). Pre-rendered to `template.HTML` | | 10 | Package declaration | None. Code generator handles it | | 11 | Frontmatter validity | Code-gen markers (not independently compilable Go) | | 12 | Prop type coercion | Runtime reflection via `mapToStruct[T]()` | @@ -847,7 +840,7 @@ virtual `.go` line numbers. |-------|--------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------| | 1 | **Parser** | `.gastro` file parser: split frontmatter from template body. Handle `---` delimiters, component import declarations, edge cases. | | 2 | **Frontmatter codegen** | Go AST analysis: extract imports (Go and component), uppercase variable capture. Generate handler functions with data maps. Store struct pointers. | -| 3 | **Template codegen** | Parse template body: transform `` tags into template function calls, handle ``, generate `html/template` code. Child content extracted into `{{define}}` blocks. Pipe expressions in props wrapped in parens. | +| 3 | **Template codegen** | Parse template body: transform `{{ render Component ... }}` and `{{ wrap Component ... }}` actions into internal template function calls, generate `html/template` code. Child content extracted into `{{define}}` blocks. Pipe expressions in props wrapped in parens. | | 4 | **Component system** | Props struct detection, `gastro.Props()` codegen, `MapToStruct[T]` runtime helper, component render functions, per-page init with component FuncMap registration, `__gastro_render_children` closure for slot content. End-to-end working. | | 5 | **File router** | Walk `pages/`, generate route table, handle `[param]` patterns, generate `Routes()` function with options. | | 6 | **Runtime library** | `gastro` package: `Context`, `Props`, `Recover`, `DefaultFuncs()`, `WithFuncs()` option, dev/prod FS abstraction. | diff --git a/docs/pages.md b/docs/pages.md index 378b9f7..9950642 100644 --- a/docs/pages.md +++ b/docs/pages.md @@ -43,9 +43,9 @@ import Layout "components/layout.gastro" Title := "About" --- - +{{ wrap Layout (dict "Title" .Title) }}

About Me

-
+{{ end }} ``` ## Data flow @@ -97,11 +97,11 @@ ctx := gastro.Context() posts, _ := db.ListPublished() Posts := posts --- - +{{ wrap Layout (dict "Title" "Home") }} {{ range .Posts }} - + {{ render PostCard (dict "Title" .Title "Slug" .Slug) }} {{ end }} - +{{ end }} ``` See [components.md](components.md) for details on the component system. diff --git a/docs/sse.md b/docs/sse.md index 2e8536c..3957e0b 100644 --- a/docs/sse.md +++ b/docs/sse.md @@ -146,10 +146,10 @@ A gastro page with Datastar attributes: import Layout "components/layout.gastro" Title := "Counter" --- - +{{ wrap Layout (dict "Title" .Title) }}
0
-
+{{ end }} ``` The layout includes the Datastar script: @@ -165,7 +165,7 @@ Title := gastro.Props().Title {{ .Title }} - +{{ .Children }} ``` diff --git a/examples/blog/components/layout.gastro b/examples/blog/components/layout.gastro index d7bb4e0..05a942b 100644 --- a/examples/blog/components/layout.gastro +++ b/examples/blog/components/layout.gastro @@ -19,7 +19,7 @@ Title := gastro.Props().Title Blog
- + {{ .Children }}

Built with Gastro

diff --git a/examples/blog/components/post-card.gastro b/examples/blog/components/post-card.gastro index 81fcbbc..30c5e8b 100644 --- a/examples/blog/components/post-card.gastro +++ b/examples/blog/components/post-card.gastro @@ -15,5 +15,5 @@ Date := gastro.Props().Date ---

{{ .Title }}

-

on {{ .Date }}

+

{{ render Badge (dict "Label" .Author) }} on {{ .Date }}

diff --git a/examples/blog/pages/about/index.gastro b/examples/blog/pages/about/index.gastro index 92cf33b..c6e12f7 100644 --- a/examples/blog/pages/about/index.gastro +++ b/examples/blog/pages/about/index.gastro @@ -3,7 +3,7 @@ import Layout "components/layout.gastro" Title := "About" --- - +{{ wrap Layout (dict "Title" .Title) }}

About Me

I'm a developer who builds things with Go.

-
+{{ end }} diff --git a/examples/blog/pages/blog/[slug].gastro b/examples/blog/pages/blog/[slug].gastro index aaaced3..22b7265 100644 --- a/examples/blog/pages/blog/[slug].gastro +++ b/examples/blog/pages/blog/[slug].gastro @@ -17,7 +17,7 @@ if err != nil { Post := post Title := post.Title --- - +{{ wrap Layout (dict "Title" .Title) }}

{{ .Post.Title }}

By {{ .Post.Author }} on {{ .Post.CreatedAt | timeFormat "Jan 2, 2006" }}

@@ -26,4 +26,4 @@ Title := post.Title
Back to all posts -
+{{ end }} diff --git a/examples/blog/pages/blog/index.gastro b/examples/blog/pages/blog/index.gastro index 4b2f5a4..4738a98 100644 --- a/examples/blog/pages/blog/index.gastro +++ b/examples/blog/pages/blog/index.gastro @@ -16,9 +16,9 @@ if err != nil { Posts := posts Title := "Blog" --- - +{{ wrap Layout (dict "Title" .Title) }}

All Posts

{{ range .Posts }} - + {{ render PostCard (dict "Slug" .Slug "Title" .Title "Author" .Author "Date" (.CreatedAt | timeFormat "Jan 2, 2006")) }} {{ end }} -
+{{ end }} diff --git a/examples/blog/pages/index.gastro b/examples/blog/pages/index.gastro index b3a50a5..d355000 100644 --- a/examples/blog/pages/index.gastro +++ b/examples/blog/pages/index.gastro @@ -16,11 +16,11 @@ if err != nil { Posts := posts Title := "Home" --- - +{{ wrap Layout (dict "Title" .Title) }}

Welcome to My Blog

{{ range .Posts }} - + {{ render PostCard (dict "Slug" .Slug "Title" .Title "Author" .Author "Date" (.CreatedAt | timeFormat "Jan 2, 2006")) }} {{ end }}
-
+{{ end }} diff --git a/examples/dashboard/components/dashboard.gastro b/examples/dashboard/components/dashboard.gastro index 89c53a7..a1d8906 100644 --- a/examples/dashboard/components/dashboard.gastro +++ b/examples/dashboard/components/dashboard.gastro @@ -50,16 +50,16 @@ Rows := rows CALL CENTER - - - - + {{ render KpiCard (dict "X" 30 "Value" .ActiveCalls "Label" "Active Calls") }} + {{ render KpiCard (dict "X" 320 "Value" .AvgWait "Label" "Avg Wait") }} + {{ render KpiCard (dict "X" 610 "Value" .CallsToday "Label" "Calls Today") }} + {{ render KpiCard (dict "X" 900 "Value" .QueueDepth "Label" "Queue Depth") }} AGENT STATUS DURATION {{ range .Rows }} - + {{ render AgentRow (dict "Agent" .Agent "Y" .Y) }} {{ end }} diff --git a/examples/dashboard/components/layout.gastro b/examples/dashboard/components/layout.gastro index 823fe13..0af25b6 100644 --- a/examples/dashboard/components/layout.gastro +++ b/examples/dashboard/components/layout.gastro @@ -15,6 +15,6 @@ Title := gastro.Props().Title - + {{ .Children }} diff --git a/examples/dashboard/pages/index.gastro b/examples/dashboard/pages/index.gastro index 023ab1e..caa9bff 100644 --- a/examples/dashboard/pages/index.gastro +++ b/examples/dashboard/pages/index.gastro @@ -3,8 +3,8 @@ import Layout "components/layout.gastro" Title := "Call Center Dashboard" --- - +{{ wrap Layout (dict "Title" .Title) }}

Connecting...

-
+{{ end }} diff --git a/examples/gastro/Dockerfile b/examples/gastro/Dockerfile new file mode 100644 index 0000000..ee96328 --- /dev/null +++ b/examples/gastro/Dockerfile @@ -0,0 +1,26 @@ +FROM golang:1.26-alpine AS build +WORKDIR /src + +# Copy the full gastro source (needed for the CLI and replace directive) +COPY go.mod go.sum* ./ +COPY cmd/ cmd/ +COPY internal/ internal/ +COPY pkg/ pkg/ + +# Build the gastro CLI +RUN go build -o /usr/local/bin/gastro ./cmd/gastro/ + +# Copy the website example +WORKDIR /app +COPY examples/gastro/ . + +# Generate Go code from .gastro files and build the binary +RUN gastro generate +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /gastro-website . + +FROM alpine:3 +RUN adduser -D -u 1000 appuser +USER appuser +COPY --from=build /gastro-website /gastro-website +EXPOSE 4242 +CMD ["/gastro-website"] diff --git a/examples/gastro/components/code-block.gastro b/examples/gastro/components/code-block.gastro new file mode 100644 index 0000000..07e3fdf --- /dev/null +++ b/examples/gastro/components/code-block.gastro @@ -0,0 +1,13 @@ +--- +type Props struct { + Code string + Language string +} + +p := gastro.Props() +Code := p.Code +Language := p.Language +--- +
+
{{ .Code }}
+
diff --git a/examples/gastro/components/counter.gastro b/examples/gastro/components/counter.gastro new file mode 100644 index 0000000..58c7a3e --- /dev/null +++ b/examples/gastro/components/counter.gastro @@ -0,0 +1,8 @@ +--- +type Props struct { + Count int +} + +Count := gastro.Props().Count +--- +
{{ .Count }}
diff --git a/examples/gastro/components/docs-layout.gastro b/examples/gastro/components/docs-layout.gastro new file mode 100644 index 0000000..ac59053 --- /dev/null +++ b/examples/gastro/components/docs-layout.gastro @@ -0,0 +1,34 @@ +--- +import Layout "components/layout.gastro" + +type Props struct { + Title string + Active string +} + +p := gastro.Props() +Title := p.Title +Active := p.Active +--- +{{ wrap Layout (dict "Title" .Title) }} +
+ +
+ {{ .Children }} +
+
+{{ end }} diff --git a/examples/gastro/components/hero.gastro b/examples/gastro/components/hero.gastro new file mode 100644 index 0000000..c162ce1 --- /dev/null +++ b/examples/gastro/components/hero.gastro @@ -0,0 +1,40 @@ +--- +type Props struct {} + +_ = gastro.Props() +--- +
+
+

A file-based component framework for Go

+

Astro's developer experience. Go's type safety. PHP's file-based routing.

+

Combine Go frontmatter with html/template markup in a single .gastro file. The compiler generates type-safe Go code with automatic routing.

+ +
+
+
+
+ + + + pages/index.gastro +
+
---
+import "time"
+
+Title := "Hello, Gastro"
+Year := time.Now().Year()
+---
+<!DOCTYPE html>
+<html>
+<head><title>{{ .Title }}</title></head>
+<body>
+    <h1>{{ .Title }}</h1>
+    <p>Copyright {{ .Year }}</p>
+</body>
+</html>
+
+
+
diff --git a/examples/gastro/components/layout.gastro b/examples/gastro/components/layout.gastro new file mode 100644 index 0000000..66318da --- /dev/null +++ b/examples/gastro/components/layout.gastro @@ -0,0 +1,44 @@ +--- +type Props struct { + Title string +} + +Title := gastro.Props().Title +--- + + + + + + {{ .Title }} - Gastro + + + + + + + {{ .Children }} +
+ +
+ + + + + + + diff --git a/examples/gastro/content/docs.go b/examples/gastro/content/docs.go new file mode 100644 index 0000000..cad0b88 --- /dev/null +++ b/examples/gastro/content/docs.go @@ -0,0 +1,448 @@ +package content + +// Code examples for documentation pages. +// These are passed as props to the CodeBlock component which renders them +// inside {{ .Code }} (auto-escaped by html/template), so raw angle brackets +// are correct here. + +// Landing page examples + +const LandingComponentExample = `--- +type Props struct { + Title string + Author string +} + +Title := gastro.Props().Title +Author := gastro.Props().Author +--- +
+

{{ .Title }}

+

By {{ .Author }}

+
` + +const LandingBuildExample = `# Generate Go code from .gastro files +gastro generate + +# Build a single binary +go build -o myapp . + +# Run it +./myapp` + +// Getting Started examples + +const GettingStartedInstall = `# Build the gastro CLI from source +go build -o gastro ./cmd/gastro/ + +# Or with mise (managed tooling) +mise install` + +const GettingStartedProjectStructure = `myapp/ + pages/ + index.gastro + static/ + styles.css + main.go + go.mod` + +const GettingStartedFirstPage = `--- +import "time" + +Title := "Hello, Gastro" +Year := time.Now().Year() +--- + + +{{ .Title }} + +

{{ .Title }}

+

Copyright {{ .Year }}

+ +` + +const GettingStartedMainGo = `package main + +import ( + "fmt" + "net/http" + "os" + + gastro "myapp/.gastro" +) + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "4242" + } + fmt.Printf("Listening on http://localhost:%s\n", port) + http.ListenAndServe(":"+port, gastro.Routes()) +}` + +const GettingStartedBuildRun = `# Generate Go code from .gastro files +gastro generate + +# Build the binary +go build -o myapp . + +# Run +./myapp` + +const GettingStartedDevMode = `# Watches for changes, rebuilds, restarts server +gastro dev` + +// Pages & Routing examples + +const PagesBasicPage = `--- +ctx := gastro.Context() + +Title := "Hello" +--- +

{{ .Title }}

` + +const PagesStaticPage = `--- +import Layout "components/layout.gastro" + +Title := "About" +--- +{{ wrap Layout (dict "Title" .Title) }} +

About Me

+{{ end }}` + +const PagesDataFlow = `--- +ctx := gastro.Context() +posts, err := db.ListPublished() +if err != nil { + ctx.Error(500, "Failed to load posts") + return +} + +Posts := posts +Title := "Blog" +--- +

{{ .Title }}

+{{ range .Posts }} +

{{ .Title }}

+{{ end }}` + +const PagesImports = `--- +import ( + "myblog/db" + + Layout "components/layout.gastro" + PostCard "components/post-card.gastro" +) + +ctx := gastro.Context() +posts, _ := db.ListPublished() +Posts := posts +--- +{{ wrap Layout (dict "Title" "Home") }} + {{ range .Posts }} + {{ render PostCard (dict "Title" .Title "Slug" .Slug) }} + {{ end }} +{{ end }}` + +const PagesDynamicRoute = `--- +import ( + "myblog/db" + Layout "components/layout.gastro" +) + +ctx := gastro.Context() +slug := ctx.Param("slug") + +post, err := db.GetBySlug(slug) +if err != nil { + ctx.Error(404, "Post not found") + return +} + +Post := post +Title := post.Title +--- +{{ wrap Layout (dict "Title" .Title) }} +
+

{{ .Post.Title }}

+

By {{ .Post.Author }}

+
{{ .Post.Body | safeHTML }}
+
+{{ end }}` + +const PagesContextRedirect = `--- +ctx := gastro.Context() + +user := getUser(ctx.Request()) +if user == nil { + ctx.Redirect("/login", 302) + return +} + +Name := user.Name +--- +

Welcome, {{ .Name }}

` + +const PagesContextQuery = `--- +ctx := gastro.Context() +Name := ctx.Query("name") +--- +

Hello, {{ .Name }}

` + +const PagesContextHeader = `--- +ctx := gastro.Context() +ctx.Header("Cache-Control", "public, max-age=3600") + +Title := "Cached Page" +--- +

{{ .Title }}

` + +// Component examples + +const ComponentBasic = `--- +type Props struct { + Title string + Author string +} + +Title := gastro.Props().Title +Author := gastro.Props().Author +--- +
+

{{ .Title }}

+

By {{ .Author }}

+
` + +const ComponentComputed = `--- +import "fmt" + +type Props struct { + Label string + X int +} + +p := gastro.Props() +Label := p.Label +CX := fmt.Sprintf("%d", p.X + 135) +--- +{{ .Label }}` + +const ComponentImportUsage = `--- +import ( + Layout "components/layout.gastro" + PostCard "components/post-card.gastro" +) + +ctx := gastro.Context() +--- +{{ wrap Layout (dict "Title" "Home") }} + {{ render PostCard (dict "Title" "My Post" "Slug" "my-post") }} +{{ end }}` + +const ComponentSlot = `--- +type Props struct { + Title string +} + +Title := gastro.Props().Title +--- + +{{ .Title }} + + +
+ {{ .Children }} +
+
...
+ +` + +const ComponentSlotUsage = `{{ wrap Layout (dict "Title" "Home") }} +

Welcome

+

This replaces the slot.

+{{ end }}` + +const ComponentPropSyntax = ` +{{ render PostCard (dict "Title" .Title "Slug" .Slug) }} + + +{{ render Layout (dict "Title" "About") }} + + +{{ render PostCard (dict "Date" (.CreatedAt | timeFormat "Jan 2, 2006")) }}` + +// SSE examples + +const SSEBasicHandler = `func handleUpdates(w http.ResponseWriter, r *http.Request) { + sse := gastro.NewSSE(w, r) + + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-sse.Context().Done(): + return + case <-ticker.C: + now := time.Now().Format("15:04:05") + sse.Send("time", now) + } + } +}` + +const SSEDatastarHandler = `var count atomic.Int64 + +func handleIncrement(w http.ResponseWriter, r *http.Request) { + n := count.Add(1) + + html, err := gastro.Render.Counter( + gastro.CounterProps{Count: int(n)}, + ) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + + sse := datastar.NewSSE(w, r) + sse.PatchElements(html) +}` + +const SSEDatastarPage = `--- +import Layout "components/layout.gastro" +Title := "Counter" +--- +{{ wrap Layout (dict "Title" .Title) }} +
0
+ +{{ end }}` + +const SSEMainGo = `func main() { + mux := http.NewServeMux() + + // API/SSE endpoints first + mux.HandleFunc("GET /api/increment", handleIncrement) + mux.HandleFunc("GET /api/clock", handleClock) + + // Gastro page routes (catch-all) + mux.Handle("/", gastro.Routes()) + + http.ListenAndServe(":4242", mux) +}` + +const SSERenderTyped = `// Each component gets a typed Render method +html, err := gastro.Render.Counter( + gastro.CounterProps{Count: 42}, +) + +// Components with slots accept optional children +inner, _ := gastro.Render.Counter( + gastro.CounterProps{Count: 42}, +) +full, _ := gastro.Render.Layout( + gastro.LayoutProps{Title: "Dashboard"}, + template.HTML(inner), +)` + +const SSEPatchOptions = `sse.PatchElements(html, + datastar.WithSelector("#dashboard"), + datastar.WithMode(datastar.ModeInner), +) + +sse.PatchSignals(map[string]any{ + "count": 42, "loading": false, +}) + +sse.RemoveElement("#toast-1")` + +// Template Helpers examples + +const HelpersStringFuncs = `{{ .Name | upper }} {{/* "ALICE" */}} +{{ .Name | lower }} {{/* "alice" */}} +{{ .Bio | trim }} {{/* trims whitespace */}} +{{ .Tags | join ", " }} {{/* "go, web, ssr" */}}` + +const HelpersSafeFuncs = `{{/* Render trusted HTML */}} +{{ .Body | safeHTML }} + +{{/* Safe attribute values */}} +
+ +{{/* Safe URLs */}} + + +{{/* Safe CSS */}} +
+ +{{/* Safe JS */}} +` + +const HelpersUtilityFuncs = `{{/* Default values */}} +{{ .Name | default "Anonymous" }} + +{{/* Time formatting */}} +{{ .CreatedAt | timeFormat "Jan 2, 2006" }} + +{{/* JSON output */}} +{{ .Config | json }} + +{{/* Build maps and lists inline */}} +{{ dict "key" "value" "other" 42 }} +{{ list "a" "b" "c" }} + +{{/* String operations */}} +{{ split .Tags "," }} +{{ contains .Title "Go" }} +{{ replace .Text "old" "new" }}` + +const HelpersCustom = `routes := gastro.Routes( + gastro.WithFuncs(template.FuncMap{ + "formatEUR": func(cents int) string { + return fmt.Sprintf("%.2f EUR", float64(cents)/100) + }, + "slugify": func(s string) string { + return strings.ToLower(strings.ReplaceAll(s, " ", "-")) + }, + }), +)` + +// Deployment examples + +const DeployBuild = `# Generate Go code from .gastro files +gastro generate + +# Cross-compile for Linux +GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o dist/myapp . + +# Deploy the single binary +scp dist/myapp server:/opt/myapp` + +const DeployOneLiner = `# Or use gastro build for generate + compile +gastro build +./app` + +const DeployDockerfile = `FROM golang:1.26-alpine AS build +WORKDIR /src + +# Install the gastro CLI +COPY . /gastro-src +RUN cd /gastro-src && go build -o /usr/local/bin/gastro ./cmd/gastro/ + +# Copy project files +COPY examples/gastro/ . + +# Generate and build +RUN gastro generate +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app . + +FROM alpine:3 +RUN adduser -D -u 1000 appuser +USER appuser +COPY --from=build /app /app +EXPOSE 4242 +CMD ["/app"]` + +const DeployEnvVars = `# Set the port via environment variable +PORT=8080 ./myapp + +# In Docker +docker run -p 8080:8080 -e PORT=8080 myapp` diff --git a/examples/gastro/go.mod b/examples/gastro/go.mod new file mode 100644 index 0000000..a3ea891 --- /dev/null +++ b/examples/gastro/go.mod @@ -0,0 +1,7 @@ +module gastro-website + +go 1.26.1 + +require github.com/andrioid/gastro v0.0.0 + +replace github.com/andrioid/gastro => ../.. diff --git a/examples/gastro/main.go b/examples/gastro/main.go new file mode 100644 index 0000000..c2743bd --- /dev/null +++ b/examples/gastro/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + "sync/atomic" + + gastro "gastro-website/.gastro" + + "github.com/andrioid/gastro/pkg/gastro/datastar" +) + +var count atomic.Int64 + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "4242" + } + + mux := http.NewServeMux() + + // SSE endpoint for the live demo counter + mux.HandleFunc("GET /api/increment", handleIncrement) + + // Gastro page routes (catch-all) + mux.Handle("/", gastro.Routes()) + + fmt.Printf("Listening on http://localhost:%s\n", port) + log.Fatal(http.ListenAndServe(":"+port, mux)) +} + +func handleIncrement(w http.ResponseWriter, r *http.Request) { + n := count.Add(1) + + html, err := gastro.Render.Counter(gastro.CounterProps{Count: int(n)}) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + sse := datastar.NewSSE(w, r) + sse.PatchElements(html) +} diff --git a/examples/gastro/pages/docs/components.gastro b/examples/gastro/pages/docs/components.gastro new file mode 100644 index 0000000..fcd90a4 --- /dev/null +++ b/examples/gastro/pages/docs/components.gastro @@ -0,0 +1,80 @@ +--- +import ( + "gastro-website/content" + + DocsLayout "components/docs-layout.gastro" + CodeBlock "components/code-block.gastro" +) + +Title := "Components" +Active := "components" +Basic := content.ComponentBasic +Computed := content.ComponentComputed +ImportUsage := content.ComponentImportUsage +Slot := content.ComponentSlot +SlotUsage := content.ComponentSlotUsage +PropSyntax := content.ComponentPropSyntax +--- +{{ wrap DocsLayout (dict "Title" .Title "Active" .Active) }} +

Components

+

Components are reusable .gastro files in the components/ directory. They accept typed props and can render children via slots.

+ +

Defining a Component

+

A component uses gastro.Props() to declare its props type. The Props struct defines what the component accepts:

+ {{ render CodeBlock (dict "Code" .Basic "Language" "go") }} +

gastro.Props() is a compile-time marker that tells the code generator this file is a component. The Props struct must be defined in the same frontmatter.

+ +

Computed Values

+

When you need derived values from multiple props, assign the whole struct first:

+ {{ render CodeBlock (dict "Code" .Computed "Language" "go") }} + +

Importing & Using Components

+

Import components in the frontmatter with the .gastro file extension. The identifier is the local name used in the template:

+ {{ render CodeBlock (dict "Code" .ImportUsage "Language" "go") }} + +

Prop Syntax

+

Props are passed as attributes on the component tag:

+ {{ render CodeBlock (dict "Code" .PropSyntax "Language" "markup") }} + + + + + + + + + +
SyntaxMeaning
{.Expr}Go template expression, evaluated in parent's data context
"literal"String literal
{.Val | func "arg"}Pipe expression
+ +

Type Coercion

+

Gastro automatically coerces prop values to match struct field types:

+ + + + + + + + + + +
Target TypeAccepted Values
stringAny value (converted via fmt.Sprintf)
boolbool, string ("true", "false")
intint, int64, float64, string (parsed)
float64float64, float32, int, string (parsed)
+ +

Slots

+

Slots let a component render content provided by its parent. Place <slot /> where children should appear:

+ {{ render CodeBlock (dict "Code" .Slot "Language" "go") }} +

The parent passes children by wrapping content in the component tags:

+ {{ render CodeBlock (dict "Code" .SlotUsage "Language" "markup") }} +

Children are rendered in the parent's data context, so they can reference the parent's template data. Only one unnamed slot is supported per component.

+ +
+{{ end }} diff --git a/examples/gastro/pages/docs/demo.gastro b/examples/gastro/pages/docs/demo.gastro new file mode 100644 index 0000000..835c93a --- /dev/null +++ b/examples/gastro/pages/docs/demo.gastro @@ -0,0 +1,48 @@ +--- +import ( + "gastro-website/content" + + DocsLayout "components/docs-layout.gastro" + CodeBlock "components/code-block.gastro" +) + +Title := "Live Demo" +Active := "demo" +HandlerCode := content.SSEDatastarHandler +PageCode := content.SSEDatastarPage +--- +{{ wrap DocsLayout (dict "Title" .Title "Active" .Active) }} +

Live Demo

+

This is a working SSE counter running on this very site. Click the button to see server-sent events in action.

+ +
+
+

Server-side counter via SSE

+
0
+ +
+
+ +
+

How It Works

+

When you click the button, Datastar sends a GET request to /api/increment. The server atomically increments a counter, renders the Counter component using the type-safe Render API, and sends an SSE event that patches the DOM.

+ +

The SSE Handler

+

This Go handler runs on the server. It uses gastro.Render.Counter() to render the component with the new count, then sends it as a Datastar patch event:

+ {{ render CodeBlock (dict "Code" .HandlerCode "Language" "go") }} + +

The Page

+

The page uses Datastar's data-on:click attribute to trigger the SSE request. No custom JavaScript needed:

+ {{ render CodeBlock (dict "Code" .PageCode "Language" "go") }} + +

This pattern works for any real-time UI: dashboards, notifications, live feeds, collaborative editing, and more. See the SSE & Datastar docs for the full API.

+
+ + +{{ end }} diff --git a/examples/gastro/pages/docs/deployment.gastro b/examples/gastro/pages/docs/deployment.gastro new file mode 100644 index 0000000..241ba0f --- /dev/null +++ b/examples/gastro/pages/docs/deployment.gastro @@ -0,0 +1,58 @@ +--- +import ( + "gastro-website/content" + + DocsLayout "components/docs-layout.gastro" + CodeBlock "components/code-block.gastro" +) + +Title := "Deployment" +Active := "deployment" +Build := content.DeployBuild +OneLiner := content.DeployOneLiner +Dockerfile := content.DeployDockerfile +EnvVars := content.DeployEnvVars +--- +{{ wrap DocsLayout (dict "Title" .Title "Active" .Active) }} +

Deployment

+

Gastro applications compile to a single binary with embedded templates and static assets. Deploy by copying one file.

+ +

Building for Production

+

Generate the Go code and cross-compile for your target platform:

+ {{ render CodeBlock (dict "Code" .Build "Language" "bash") }} +

Or use the shorthand:

+ {{ render CodeBlock (dict "Code" .OneLiner "Language" "bash") }} +

The resulting binary contains everything: your Go handlers, compiled templates, and static assets from static/. No runtime dependencies.

+ +

Docker

+

A multi-stage Dockerfile keeps the image small. The build stage compiles everything, and the runtime stage contains only the binary:

+ {{ render CodeBlock (dict "Code" .Dockerfile "Language" "docker") }} +

The runtime image uses Alpine Linux with a non-root user for security. The final image is typically under 20MB.

+ +

Environment Variables

+

The only configuration is the PORT environment variable:

+ {{ render CodeBlock (dict "Code" .EnvVars "Language" "bash") }} +

If PORT is not set, the server defaults to port 4242.

+ +

Platform Guides

+

The Docker image works with any container platform:

+
    +
  • Fly.io — fly launch auto-detects the Dockerfile
  • +
  • Railway — connect your repo, Railway builds from the Dockerfile
  • +
  • Google Cloud Run — gcloud run deploy --source .
  • +
  • AWS ECS / Fargate — build the image and push to ECR
  • +
  • Any VPS — copy the binary directly with scp
  • +
+

Since Gastro builds a static binary with no runtime dependencies, you can also deploy without Docker by copying the binary to any Linux server.

+ + +{{ end }} diff --git a/examples/gastro/pages/docs/getting-started.gastro b/examples/gastro/pages/docs/getting-started.gastro new file mode 100644 index 0000000..7c1a39a --- /dev/null +++ b/examples/gastro/pages/docs/getting-started.gastro @@ -0,0 +1,61 @@ +--- +import ( + "gastro-website/content" + + DocsLayout "components/docs-layout.gastro" + CodeBlock "components/code-block.gastro" +) + +Title := "Getting Started" +Active := "getting-started" +Install := content.GettingStartedInstall +ProjectStructure := content.GettingStartedProjectStructure +FirstPage := content.GettingStartedFirstPage +MainGo := content.GettingStartedMainGo +BuildRun := content.GettingStartedBuildRun +DevMode := content.GettingStartedDevMode +--- +{{ wrap DocsLayout (dict "Title" .Title "Active" .Active) }} +

Getting Started

+

Set up your first Gastro project in under a minute. You'll need Go 1.26+ installed.

+ +

Install the CLI

+

Build the gastro CLI from source. If you use mise, it will manage your Go toolchain automatically.

+ {{ render CodeBlock (dict "Code" .Install "Language" "bash") }} + +

Project Structure

+

A Gastro project has a simple, opinionated layout:

+ {{ render CodeBlock (dict "Code" .ProjectStructure "Language" "bash") }} +
    +
  • pages/ — .gastro files that become HTTP routes
  • +
  • components/ — reusable .gastro components
  • +
  • static/ — CSS, images, and other static files served at /static/
  • +
  • main.go — your application entry point
  • +
+ +

Your First Page

+

Create pages/index.gastro. The code between --- delimiters is Go frontmatter that runs on the server. The HTML below is rendered with Go's html/template.

+ {{ render CodeBlock (dict "Code" .FirstPage "Language" "go") }} +

Uppercase variables like Title and Year are automatically exported to the template as {{ "{{ .Title }}" }} and {{ "{{ .Year }}" }}. Lowercase variables stay private.

+ +

Entry Point

+

Create main.go that imports the generated code and starts the server:

+ {{ render CodeBlock (dict "Code" .MainGo "Language" "go") }} +

The .gastro/ directory contains generated Go code. Import it as a package with any alias you like.

+ +

Build & Run

+ {{ render CodeBlock (dict "Code" .BuildRun "Language" "bash") }} +

Open http://localhost:4242 to see your page.

+ +

Development Mode

+

For development, use the gastro dev command. It watches for file changes, regenerates code, and restarts the server automatically. Template changes are hot-reloaded without a restart.

+ {{ render CodeBlock (dict "Code" .DevMode "Language" "bash") }} + + +{{ end }} diff --git a/examples/gastro/pages/docs/helpers.gastro b/examples/gastro/pages/docs/helpers.gastro new file mode 100644 index 0000000..af472df --- /dev/null +++ b/examples/gastro/pages/docs/helpers.gastro @@ -0,0 +1,83 @@ +--- +import ( + "gastro-website/content" + + DocsLayout "components/docs-layout.gastro" + CodeBlock "components/code-block.gastro" +) + +Title := "Template Helpers" +Active := "helpers" +StringFuncs := content.HelpersStringFuncs +SafeFuncs := content.HelpersSafeFuncs +UtilityFuncs := content.HelpersUtilityFuncs +Custom := content.HelpersCustom +--- +{{ wrap DocsLayout (dict "Title" .Title "Active" .Active) }} +

Template Helpers

+

Gastro provides 18 built-in template functions available in all templates without registration. You can also add custom helpers.

+ +

String Functions

+ {{ render CodeBlock (dict "Code" .StringFuncs "Language" "go") }} + + + + + + + + + + + + + +
FunctionDescription
upperConverts string to uppercase
lowerConverts string to lowercase
trimTrims leading and trailing whitespace
joinJoins a slice of strings with a separator
splitSplits a string by separator
containsChecks if a string contains a substring
replaceReplaces occurrences in a string
+ +

Safety Functions

+

These functions mark content as safe for specific contexts, bypassing html/template's automatic escaping. Use them only with trusted content:

+ {{ render CodeBlock (dict "Code" .SafeFuncs "Language" "go") }} + + + + + + + + + + + +
FunctionMarks safe for
safeHTMLHTML content (renders without escaping)
safeAttrHTML attribute values
safeURLURL values in href/src attributes
safeCSSCSS property values
safeJSJavaScript values
+ +

Utility Functions

+ {{ render CodeBlock (dict "Code" .UtilityFuncs "Language" "go") }} + + + + + + + + + + + +
FunctionDescription
defaultReturns value, or fallback if empty/zero
timeFormatFormats a time.Time using Go's layout syntax
jsonJSON-encodes a value
dictCreates a map[string]any from key-value pairs
listCreates a []any from arguments
+ +

Custom Helpers

+

Register custom template functions in your main.go using gastro.WithFuncs():

+ {{ render CodeBlock (dict "Code" .Custom "Language" "go") }} +

Custom functions are available in all pages and components, just like the built-in helpers.

+ + +{{ end }} diff --git a/examples/gastro/pages/docs/pages.gastro b/examples/gastro/pages/docs/pages.gastro new file mode 100644 index 0000000..c9ac962 --- /dev/null +++ b/examples/gastro/pages/docs/pages.gastro @@ -0,0 +1,94 @@ +--- +import ( + "gastro-website/content" + + DocsLayout "components/docs-layout.gastro" + CodeBlock "components/code-block.gastro" +) + +Title := "Pages & Routing" +Active := "pages" +BasicPage := content.PagesBasicPage +StaticPage := content.PagesStaticPage +DataFlow := content.PagesDataFlow +Imports := content.PagesImports +DynamicRoute := content.PagesDynamicRoute +Redirect := content.PagesContextRedirect +Query := content.PagesContextQuery +Header := content.PagesContextHeader +--- +{{ wrap DocsLayout (dict "Title" .Title "Active" .Active) }} +

Pages & Routing

+

Pages are .gastro files in the pages/ directory. Each page becomes an HTTP route automatically.

+ +

File Format

+

A page has two sections separated by --- delimiters: Go frontmatter and an HTML template body.

+ {{ render CodeBlock (dict "Code" .BasicPage "Language" "go") }} +

Call gastro.Context() in the frontmatter to mark the file as a page and get access to the HTTP request.

+ +

Static Pages

+

Pages that don't need request access can omit gastro.Context(). These are static pages that only use component imports and exported variables:

+ {{ render CodeBlock (dict "Code" .StaticPage "Language" "go") }} + +

Data Flow

+

Variables follow Go's export convention:

+
    +
  • Uppercase variables (Title, Posts) are exported to the template
  • +
  • Lowercase variables (err, slug) are private to the frontmatter
  • +
+ {{ render CodeBlock (dict "Code" .DataFlow "Language" "go") }} + +

Imports

+

Use Go import for both packages and components. Component imports are distinguished by the .gastro file extension:

+ {{ render CodeBlock (dict "Code" .Imports "Language" "go") }} + +

File-Based Routing

+

Page files map to HTTP routes automatically:

+ + + + + + + + + + +
FileRoute
pages/index.gastroGET /
pages/about/index.gastroGET /about
pages/blog/index.gastroGET /blog
pages/blog/[slug].gastroGET /blog/{slug}
+

Square brackets denote dynamic segments: [slug] becomes {slug} in Go 1.22+ router patterns. Only GET routes are generated.

+ +

Dynamic Routes

+

Access URL parameters with ctx.Param():

+ {{ render CodeBlock (dict "Code" .DynamicRoute "Language" "go") }} + +

Context API

+

gastro.Context() returns a *Context with methods for request handling:

+ +

Query Parameters

+ {{ render CodeBlock (dict "Code" .Query "Language" "go") }} + +

Redirects

+

Always call return after a redirect to prevent the template from rendering:

+ {{ render CodeBlock (dict "Code" .Redirect "Language" "go") }} + +

Response Headers

+ {{ render CodeBlock (dict "Code" .Header "Language" "go") }} + +

Error Handling

+

Two layers protect your application:

+
    +
  1. Explicit errors — use ctx.Error(code, msg) + return for controlled error responses
  2. +
  3. Panic recovery — all handlers are wrapped in defer gastro.Recover(w, r) which catches panics and returns a 500 error
  4. +
+ + +{{ end }} diff --git a/examples/gastro/pages/docs/sse.gastro b/examples/gastro/pages/docs/sse.gastro new file mode 100644 index 0000000..0e7179b --- /dev/null +++ b/examples/gastro/pages/docs/sse.gastro @@ -0,0 +1,88 @@ +--- +import ( + "gastro-website/content" + + DocsLayout "components/docs-layout.gastro" + CodeBlock "components/code-block.gastro" +) + +Title := "SSE & Datastar" +Active := "sse" +BasicHandler := content.SSEBasicHandler +DatastarHandler := content.SSEDatastarHandler +DatastarPage := content.SSEDatastarPage +MainGo := content.SSEMainGo +RenderTyped := content.SSERenderTyped +PatchOptions := content.SSEPatchOptions +--- +{{ wrap DocsLayout (dict "Title" .Title "Active" .Active) }} +

SSE & Datastar

+

Gastro provides a lightweight SSE helper for streaming events from the server to the browser, enabling real-time UI updates with Datastar and HTMX.

+ +

How It Works

+
    +
  1. A Gastro page renders the initial HTML (as usual)
  2. +
  3. Client-side attributes open an SSE connection to an API endpoint
  4. +
  5. Your Go handler writes SSE events that patch the DOM
  6. +
+

SSE endpoints are plain Go HTTP handlers — no compiler changes needed. Register them alongside Gastro routes in your main.go.

+ +

Generic SSE

+

The core SSE helper in pkg/gastro is framework-agnostic. It works with any client that consumes text/event-stream:

+ {{ render CodeBlock (dict "Code" .BasicHandler "Language" "go") }} +

Methods available on the SSE helper:

+
    +
  • Send(eventType, data ...string) — writes and flushes a single SSE event
  • +
  • IsClosed() — reports whether the client disconnected
  • +
  • Context() — returns the request context for select loops
  • +
+ +

Datastar Integration

+

The pkg/gastro/datastar subpackage formats events using Datastar's SSE protocol:

+ {{ render CodeBlock (dict "Code" .DatastarHandler "Language" "go") }} + +

Datastar Page

+

Add Datastar attributes to your .gastro pages to trigger SSE connections:

+ {{ render CodeBlock (dict "Code" .DatastarPage "Language" "go") }} + +

Patch Options

+

Datastar supports selectors, patch modes, and signal patching:

+ {{ render CodeBlock (dict "Code" .PatchOptions "Language" "go") }} + +

Wiring It Up

+

Create a top-level http.ServeMux and mount both API routes and Gastro page routes:

+ {{ render CodeBlock (dict "Code" .MainGo "Language" "go") }} + +

Type-Safe Rendering

+

The compiler generates a Render API for calling Gastro components from SSE handlers with full type safety:

+ {{ render CodeBlock (dict "Code" .RenderTyped "Language" "go") }} + + + + + + + + + +
WhatSafety
Method nameCompile-time — method exists or doesn't
Props fieldsCompile-time — struct fields checked by Go
Props typesCompile-time — Go type system
+ +

Design Notes

+
    +
  • No external dependencies. The SSE protocol is ~90 lines of Go.
  • +
  • Two layers. Generic pkg/gastro works with any SSE client. pkg/gastro/datastar adds Datastar-specific formatting.
  • +
  • Render wraps internal functions. Each method calls the internal component function, preserving all frontmatter logic.
  • +
+

Try the live SSE demo →

+ + +{{ end }} diff --git a/examples/gastro/pages/index.gastro b/examples/gastro/pages/index.gastro new file mode 100644 index 0000000..8810b38 --- /dev/null +++ b/examples/gastro/pages/index.gastro @@ -0,0 +1,70 @@ +--- +import ( + "gastro-website/content" + + Layout "components/layout.gastro" + Hero "components/hero.gastro" + CodeBlock "components/code-block.gastro" +) + +Title := "Home" +ComponentCode := content.LandingComponentExample +BuildCode := content.LandingBuildExample +--- +{{ wrap Layout (dict "Title" .Title) }} + {{ render Hero (dict) }} + +
+

Why Gastro?

+

Everything you need for productive server-rendered Go web development.

+
+
+
+

File-Based Routing

+

Pages in your pages/ directory automatically become routes. Dynamic parameters with [slug] syntax. No configuration needed.

+
+
+
+

Type-Safe Components

+

Define Props as Go structs. The compiler checks every prop at build time. No runtime surprises.

+
+
+
+

Go Frontmatter

+

Server-side Go code and HTML templates live in one file. Uppercase variables are automatically exported to the template.

+
+
+
+

Zero Dependencies

+

Built entirely on Go's standard library. No external runtime dependencies. net/http, html/template, and embed.

+
+
+
+

Single Binary Deploy

+

Templates and static assets are embedded at build time. Ship one file anywhere. No runtime dependencies.

+
+
+
+

SSE & Real-time

+

Built-in Server-Sent Events with Datastar integration. Type-safe component rendering for SSE handlers.

+
+
+
+ +
+
+

Quick Start

+

Get a Gastro project running in under a minute.

+
+
+

Write a component

+ {{ render CodeBlock (dict "Code" .ComponentCode "Language" "go") }} +
+
+

Build & run

+ {{ render CodeBlock (dict "Code" .BuildCode "Language" "bash") }} +
+
+
+
+{{ end }} diff --git a/examples/gastro/static/logo.svg b/examples/gastro/static/logo.svg new file mode 100644 index 0000000..279965d --- /dev/null +++ b/examples/gastro/static/logo.svg @@ -0,0 +1,5 @@ + + + + Gastro + diff --git a/examples/gastro/static/styles.css b/examples/gastro/static/styles.css new file mode 100644 index 0000000..df550a1 --- /dev/null +++ b/examples/gastro/static/styles.css @@ -0,0 +1,817 @@ +/* ============================================================ + Gastro Website Styles + ============================================================ */ + +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --color-bg: #ffffff; + --color-text: #1a1a2e; + --color-text-muted: #64748b; + --color-text-subtle: #94a3b8; + --color-accent: #f97316; + --color-accent-hover: #ea580c; + --color-accent-light: #fff7ed; + --color-border: #e2e8f0; + --color-border-light: #f1f5f9; + --color-surface: #f8fafc; + --color-code-bg: #1e1e2e; + --color-code-text: #cdd6f4; + --color-header-bg: #0f172a; + --color-header-text: #f8fafc; + --color-hero-bg: #0f172a; + --font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", ui-monospace, "SF Mono", monospace; + --max-width: 1120px; + --radius: 8px; + --radius-lg: 12px; +} + +html { + font-size: 16px; + scroll-behavior: smooth; +} + +body { + font-family: var(--font-sans); + line-height: 1.7; + color: var(--color-text); + background: var(--color-bg); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + color: var(--color-accent); + text-decoration: none; + transition: color 0.15s ease; +} + +a:hover { + color: var(--color-accent-hover); +} + +code { + font-family: var(--font-mono); + font-size: 0.875em; +} + +/* Inline code in prose */ +p code, +li code, +td code, +h1 code, +h2 code, +h3 code { + background: var(--color-surface); + border: 1px solid var(--color-border); + padding: 0.125em 0.375em; + border-radius: 4px; + font-size: 0.85em; +} + +/* ============================================================ + Site Header + ============================================================ */ + +.site-header { + background: var(--color-header-bg); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + position: sticky; + top: 0; + z-index: 100; +} + +.nav-container { + max-width: var(--max-width); + margin: 0 auto; + padding: 0 2rem; + height: 60px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.nav-logo { + display: flex; + align-items: center; + color: var(--color-header-text); +} + +.nav-logo img { + height: 28px; +} + +.nav-links { + display: flex; + gap: 2rem; + align-items: center; +} + +.nav-links a { + color: var(--color-text-subtle); + font-size: 0.9rem; + font-weight: 500; + transition: color 0.15s ease; +} + +.nav-links a:hover { + color: var(--color-header-text); +} + +/* ============================================================ + Hero Section + ============================================================ */ + +.hero { + background: var(--color-hero-bg); + padding: 5rem 2rem 6rem; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4rem; + max-width: var(--max-width); + margin: 0 auto; +} + +/* Extend the hero background full-width */ +.hero { + max-width: none; + padding-left: calc((100vw - var(--max-width)) / 2 + 2rem); + padding-right: calc((100vw - var(--max-width)) / 2 + 2rem); +} + +.hero-content { + display: flex; + flex-direction: column; + justify-content: center; +} + +.hero-title { + font-size: 2.75rem; + font-weight: 800; + line-height: 1.15; + color: var(--color-header-text); + letter-spacing: -0.02em; + margin-bottom: 1.25rem; +} + +.hero-accent { + color: var(--color-accent); +} + +.hero-subtitle { + font-size: 1.15rem; + color: var(--color-accent); + font-weight: 600; + margin-bottom: 1rem; + letter-spacing: -0.01em; +} + +.hero-description { + font-size: 1.05rem; + color: var(--color-text-subtle); + line-height: 1.7; + margin-bottom: 2rem; +} + +.hero-actions { + display: flex; + gap: 1rem; +} + +/* ============================================================ + Buttons + ============================================================ */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.7rem 1.5rem; + border-radius: var(--radius); + font-size: 0.95rem; + font-weight: 600; + text-decoration: none; + transition: all 0.2s ease; + border: 2px solid transparent; + cursor: pointer; +} + +.btn-primary { + background: var(--color-accent); + color: white; + border-color: var(--color-accent); +} + +.btn-primary:hover { + background: var(--color-accent-hover); + border-color: var(--color-accent-hover); + color: white; +} + +.btn-secondary { + background: transparent; + color: var(--color-header-text); + border-color: rgba(255, 255, 255, 0.2); +} + +.btn-secondary:hover { + border-color: rgba(255, 255, 255, 0.4); + color: white; +} + +/* ============================================================ + Code Window (Hero) + ============================================================ */ + +.code-window { + background: var(--color-code-bg); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); +} + +.code-window-bar { + display: flex; + align-items: center; + padding: 0.75rem 1rem; + background: rgba(0, 0, 0, 0.3); + gap: 0.5rem; +} + +.code-dot { + width: 12px; + height: 12px; + border-radius: 50%; +} + +.code-dot.red { background: #ff5f57; } +.code-dot.yellow { background: #febc2e; } +.code-dot.green { background: #28c840; } + +.code-window-title { + margin-left: 0.75rem; + font-size: 0.8rem; + color: var(--color-text-subtle); + font-family: var(--font-mono); +} + +.code-window pre { + margin: 0; + padding: 1.25rem; + overflow-x: auto; +} + +.code-window pre code { + font-size: 0.85rem; + line-height: 1.6; +} + +/* Override Prism background inside code-window */ +.code-window pre[class*="language-"], +.code-window code[class*="language-"] { + background: transparent; +} + +/* ============================================================ + Features Section + ============================================================ */ + +.features-section { + padding: 5rem 2rem; + max-width: var(--max-width); + margin: 0 auto; +} + +.features-section h2 { + text-align: center; + font-size: 2rem; + font-weight: 700; + margin-bottom: 0.5rem; + letter-spacing: -0.02em; +} + +.features-section > p { + text-align: center; + color: var(--color-text-muted); + margin-bottom: 3rem; + font-size: 1.05rem; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; +} + +.feature-card { + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 2rem; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.feature-card:hover { + border-color: var(--color-accent); + box-shadow: 0 4px 16px rgba(249, 115, 22, 0.08); +} + +.feature-icon { + font-size: 2rem; + margin-bottom: 1rem; + line-height: 1; +} + +.feature-card h3 { + font-size: 1.1rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.feature-card p { + color: var(--color-text-muted); + font-size: 0.95rem; + line-height: 1.6; +} + +/* ============================================================ + Quick Start Section (Landing Page) + ============================================================ */ + +.quickstart-section { + background: var(--color-surface); + border-top: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--color-border-light); + padding: 5rem 2rem; +} + +.quickstart-container { + max-width: var(--max-width); + margin: 0 auto; +} + +.quickstart-section h2 { + text-align: center; + font-size: 2rem; + font-weight: 700; + margin-bottom: 0.5rem; + letter-spacing: -0.02em; +} + +.quickstart-section > .quickstart-container > p { + text-align: center; + color: var(--color-text-muted); + margin-bottom: 3rem; + font-size: 1.05rem; +} + +.quickstart-steps { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; +} + +.quickstart-step { + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 1.5rem; +} + +.quickstart-step h3 { + font-size: 0.85rem; + font-weight: 600; + color: var(--color-accent); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 1rem; +} + +/* ============================================================ + Docs Layout + ============================================================ */ + +.docs-container { + max-width: var(--max-width); + margin: 0 auto; + padding: 2rem; + display: grid; + grid-template-columns: 220px 1fr; + gap: 3rem; + min-height: calc(100vh - 60px - 120px); +} + +.docs-sidebar { + position: sticky; + top: 76px; + align-self: start; + padding-top: 1rem; +} + +.docs-sidebar h3 { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-text-muted); + margin-bottom: 0.75rem; + margin-top: 1.5rem; +} + +.docs-sidebar h3:first-child { + margin-top: 0; +} + +.docs-sidebar ul { + list-style: none; + padding: 0; + margin: 0; +} + +.docs-sidebar li { + margin-bottom: 0.25rem; +} + +.docs-sidebar a { + display: block; + padding: 0.35rem 0.75rem; + border-radius: 6px; + color: var(--color-text-muted); + font-size: 0.9rem; + font-weight: 500; + transition: all 0.15s ease; +} + +.docs-sidebar a:hover { + color: var(--color-text); + background: var(--color-surface); +} + +.docs-sidebar a.active { + color: var(--color-accent); + background: var(--color-accent-light); + font-weight: 600; +} + +/* ============================================================ + Docs Content + ============================================================ */ + +.docs-content { + padding-top: 1rem; + padding-bottom: 4rem; + max-width: 760px; +} + +.docs-content h1 { + font-size: 2rem; + font-weight: 800; + margin-bottom: 0.5rem; + letter-spacing: -0.02em; +} + +.docs-content h1 + p { + font-size: 1.1rem; + color: var(--color-text-muted); + margin-bottom: 2rem; + line-height: 1.7; +} + +.docs-content h2 { + font-size: 1.4rem; + font-weight: 700; + margin-top: 3rem; + margin-bottom: 1rem; + padding-top: 1.5rem; + border-top: 1px solid var(--color-border-light); + letter-spacing: -0.01em; +} + +.docs-content h2:first-of-type { + margin-top: 2rem; + border-top: none; + padding-top: 0; +} + +.docs-content h3 { + font-size: 1.1rem; + font-weight: 700; + margin-top: 2rem; + margin-bottom: 0.75rem; +} + +.docs-content p { + margin-bottom: 1rem; + color: var(--color-text); +} + +.docs-content ul, +.docs-content ol { + margin-bottom: 1.25rem; + padding-left: 1.5rem; +} + +.docs-content li { + margin-bottom: 0.4rem; + color: var(--color-text); +} + +.docs-content strong { + font-weight: 600; +} + +.docs-content blockquote { + border-left: 3px solid var(--color-accent); + margin: 1.5rem 0; + padding: 0.75rem 1.25rem; + background: var(--color-accent-light); + border-radius: 0 var(--radius) var(--radius) 0; +} + +.docs-content blockquote p { + margin: 0; + color: var(--color-text); +} + +/* ============================================================ + Code Blocks (Docs) + ============================================================ */ + +.code-block { + margin: 1.25rem 0; + border-radius: var(--radius); + overflow: hidden; + border: 1px solid rgba(30, 30, 46, 0.1); +} + +.code-block pre { + margin: 0; + padding: 1.25rem; + overflow-x: auto; + background: var(--color-code-bg); +} + +.code-block pre code { + font-size: 0.85rem; + line-height: 1.65; + color: var(--color-code-text); +} + +/* Override Prism theme defaults inside code-block */ +.code-block pre[class*="language-"], +.code-block code[class*="language-"] { + background: transparent; +} + +/* ============================================================ + Tables (Docs) + ============================================================ */ + +.docs-content table { + width: 100%; + border-collapse: collapse; + margin: 1.25rem 0; + font-size: 0.9rem; +} + +.docs-content th { + text-align: left; + padding: 0.7rem 1rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + font-weight: 600; + font-size: 0.85rem; +} + +.docs-content td { + padding: 0.6rem 1rem; + border: 1px solid var(--color-border); + vertical-align: top; +} + +/* ============================================================ + Live Demo + ============================================================ */ + +.demo-section { + text-align: center; + padding: 3rem 0; +} + +.demo-container { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; + padding: 3rem 4rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.demo-count { + font-size: 4rem; + font-weight: 800; + font-family: var(--font-mono); + color: var(--color-accent); + line-height: 1; + min-width: 120px; +} + +.demo-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.7rem 2rem; + background: var(--color-accent); + color: white; + border: none; + border-radius: var(--radius); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease; + font-family: var(--font-sans); +} + +.demo-button:hover { + background: var(--color-accent-hover); +} + +.demo-explanation { + margin-top: 2rem; + text-align: left; +} + +.demo-explanation h3 { + margin-bottom: 1rem; +} + +/* ============================================================ + Next/Prev Navigation (Docs) + ============================================================ */ + +.docs-nav { + display: flex; + justify-content: space-between; + margin-top: 4rem; + padding-top: 2rem; + border-top: 1px solid var(--color-border); + gap: 1rem; +} + +.docs-nav a { + display: block; + padding: 1rem 1.25rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + transition: border-color 0.2s ease; + min-width: 180px; +} + +.docs-nav a:hover { + border-color: var(--color-accent); +} + +.docs-nav-label { + font-size: 0.75rem; + color: var(--color-text-muted); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.25rem; +} + +.docs-nav-title { + font-weight: 600; + color: var(--color-text); +} + +.docs-nav .next { + text-align: right; + margin-left: auto; +} + +/* ============================================================ + Footer + ============================================================ */ + +.site-footer { + border-top: 1px solid var(--color-border); + padding: 2.5rem 2rem; + background: var(--color-surface); +} + +.footer-container { + max-width: var(--max-width); + margin: 0 auto; + text-align: center; +} + +.site-footer p { + color: var(--color-text-muted); + font-size: 0.9rem; + margin-bottom: 0.25rem; +} + +.site-footer a { + color: var(--color-text-muted); + font-weight: 500; +} + +.site-footer a:hover { + color: var(--color-accent); +} + +/* ============================================================ + Responsive + ============================================================ */ + +@media (max-width: 900px) { + .hero { + grid-template-columns: 1fr; + padding: 3rem 1.5rem 4rem; + text-align: center; + } + + .hero-actions { + justify-content: center; + } + + .hero-code { + max-width: 540px; + margin: 0 auto; + } + + .features-grid { + grid-template-columns: 1fr; + } + + .quickstart-steps { + grid-template-columns: 1fr; + } + + .docs-container { + grid-template-columns: 1fr; + padding: 1.5rem; + gap: 1.5rem; + } + + .docs-sidebar { + position: static; + border-bottom: 1px solid var(--color-border); + padding-bottom: 1.5rem; + } + + .hero-title { + font-size: 2rem; + } +} + +@media (max-width: 600px) { + .hero-title { + font-size: 1.75rem; + } + + .hero-actions { + flex-direction: column; + } + + .demo-container { + padding: 2rem; + } + + .demo-count { + font-size: 3rem; + } + + .docs-nav { + flex-direction: column; + } + + .docs-nav .next { + text-align: left; + } +} + +/* ============================================================ + Reduced motion + ============================================================ */ + +@media (prefers-reduced-motion: reduce) { + * { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } + + html { + scroll-behavior: auto; + } +} diff --git a/examples/sse/components/layout.gastro b/examples/sse/components/layout.gastro index cac5245..a0bde89 100644 --- a/examples/sse/components/layout.gastro +++ b/examples/sse/components/layout.gastro @@ -15,7 +15,7 @@ Title := gastro.Props().Title
- + {{ .Children }}
diff --git a/examples/sse/pages/index.gastro b/examples/sse/pages/index.gastro index 348697d..ad67f5a 100644 --- a/examples/sse/pages/index.gastro +++ b/examples/sse/pages/index.gastro @@ -3,7 +3,7 @@ import Layout "components/layout.gastro" Title := "SSE + Datastar Example" --- - +{{ wrap Layout (dict "Title" .Title) }}

SSE + Datastar Counter

Click the button to increment the counter via a server-sent event.

@@ -13,4 +13,4 @@ Title := "SSE + Datastar Example"

Live Clock

This clock updates every second via an SSE stream.

--:--:--
-
+{{ end }} diff --git a/internal/codegen/template.go b/internal/codegen/template.go index a97cd2c..5aefd1a 100644 --- a/internal/codegen/template.go +++ b/internal/codegen/template.go @@ -4,197 +4,321 @@ import ( "fmt" "regexp" "strings" - "unicode" "github.com/andrioid/gastro/internal/parser" ) -// TransformTemplate transforms the template body, converting tags -// into Go template function calls and into {{ .Children }}. +// renderRegex matches {{ render ComponentName ... }} where ComponentName is PascalCase. +var renderRegex = regexp.MustCompile(`\{\{\s*render\s+([A-Z][a-zA-Z0-9]*)(\s*)`) + +// wrapRegex matches {{ wrap ComponentName ... }} where ComponentName is PascalCase. +var wrapRegex = regexp.MustCompile(`\{\{\s*wrap\s+([A-Z][a-zA-Z0-9]*)(\s*)`) + +// oldPropSyntaxRegex detects old Gastro-specific {.Expr} prop syntax in HTML tags. +// This pattern cannot appear in valid HTML, so it's a reliable migration signal. +var oldPropSyntaxRegex = regexp.MustCompile(`<[A-Z][a-zA-Z0-9]*[^>]*\w+=\{[^}]+\}`) + +// commentRegex matches Go template comments {{/* ... */}}. +var commentRegex = regexp.MustCompile(`\{\{/\*[\s\S]*?\*/\}\}`) + +const commentPlaceholder = "\x00__GASTRO_COMMENT_" + +// extractComments removes Go template comments from the body, replacing them +// with null-byte-delimited placeholders. This prevents the render/wrap regexes +// from matching inside comments. +func extractComments(body string) (string, []string) { + var comments []string + result := commentRegex.ReplaceAllStringFunc(body, func(match string) string { + comments = append(comments, match) + return fmt.Sprintf("%s%d\x00", commentPlaceholder, len(comments)-1) + }) + return result, comments +} + +func restoreComments(body string, comments []string) string { + for i, c := range comments { + placeholder := fmt.Sprintf("%s%d\x00", commentPlaceholder, i) + body = strings.Replace(body, placeholder, c, 1) + } + return body +} + +// TransformTemplate transforms the template body: +// - {{ render ComponentName (dict ...) }} → {{ __gastro_ComponentName (dict ...) }} +// - {{ wrap ComponentName (dict ...) }}...{{ end }} → function call + {{define}} block +// +// Component names must be imported via UseDeclaration. Unknown components produce errors. func TransformTemplate(body string, uses []parser.UseDeclaration) (string, error) { - knownComponents := make(map[string]bool, len(uses)) + known := make(map[string]bool, len(uses)) for _, u := range uses { - knownComponents[u.Name] = true + known[u.Name] = true } - result := body + // Detect old HTML-like syntax and provide migration hints + if err := detectOldSyntax(body, known); err != nil { + return "", err + } - // Replace with {{ .Children }} - result = replaceSlotTags(result) + // Extract comments to prevent regexes from matching inside them + body, comments := extractComments(body) - // Replace component tags (both self-closing and with children) - var err error - result, err = replaceComponents(result, knownComponents) + // Transform {{ render X ... }} calls (leaf components) + result, err := transformRender(body, known) if err != nil { return "", err } - return result, nil -} - -var slotRegex = regexp.MustCompile(``) - -func replaceSlotTags(body string) string { - return slotRegex.ReplaceAllString(body, "{{ .Children }}") -} - -// replaceComponents processes the template body, replacing component tags. -// Handles both self-closing and open/close ... tags. -// Uses iterative string scanning instead of regex to correctly handle nesting. -func replaceComponents(body string, known map[string]bool) (string, error) { - // Keep processing until no more component tags are found. - // Process innermost components first (self-closing), then outer wrappers. + // Transform {{ wrap X ... }}...{{ end }} blocks (components with children) + childIdx := 0 for { - changed := false - - // First pass: replace self-closing - newBody, didChange, err := replaceSelfClosing(body, known) - if err != nil { - return "", err + newResult, changed, wrapErr := transformOneWrap(result, known, &childIdx) + if wrapErr != nil { + return "", wrapErr } - if didChange { - body = newBody - changed = true - } - - // Second pass: replace innermost ... (no nested same-name tags) - newBody, didChange, err = replaceWithChildren(body, known) - if err != nil { - return "", err - } - if didChange { - body = newBody - changed = true - } - if !changed { break } + result = newResult } - return body, nil -} + // Restore comments + result = restoreComments(result, comments) -// selfClosingTagRegex matches a self-closing component tag on a single logical -// unit. It requires the tag name to start with uppercase, and matches props -// up to the closing />. The key constraint: no > character in the props -// (which prevents matching across line boundaries into other tags). -var selfClosingTagRegex = regexp.MustCompile(`<([A-Z][a-zA-Z0-9]*)((?:\s+\w+=(?:\{[^}]*\}|"[^"]*"))*)\s*/>`) + return result, nil +} -func replaceSelfClosing(body string, known map[string]bool) (string, bool, error) { - didChange := false +// transformRender replaces all {{ render X ... }} with {{ __gastro_X ... }}. +func transformRender(body string, known map[string]bool) (string, error) { var replaceErr error - result := selfClosingTagRegex.ReplaceAllStringFunc(body, func(match string) string { + result := renderRegex.ReplaceAllStringFunc(body, func(match string) string { if replaceErr != nil { return match } - groups := selfClosingTagRegex.FindStringSubmatch(match) + groups := renderRegex.FindStringSubmatch(match) name := groups[1] - propsStr := strings.TrimSpace(groups[2]) if !known[name] { - replaceErr = fmt.Errorf("unknown component <%s />: not imported", name) + replaceErr = fmt.Errorf("unknown component %q in {{ render }}: not imported", name) return match } - didChange = true - dictCall := buildDictCall(propsStr) - return fmt.Sprintf("{{ __gastro_%s (%s) }}", name, dictCall) + return strings.Replace(match, "render "+name, "__gastro_"+name, 1) }) - return result, didChange, replaceErr + return result, replaceErr } -// openTagRegex matches a component open tag: -// It matches the FIRST occurrence of a PascalCase open tag. -var openTagRegex = regexp.MustCompile(`<([A-Z][a-zA-Z0-9]*)((?:\s+\w+=(?:\{[^}]*\}|"[^"]*"))*)\s*>`) - -func replaceWithChildren(body string, known map[string]bool) (string, bool, error) { - loc := openTagRegex.FindStringIndex(body) +// transformOneWrap finds the first {{ wrap X ... }} block, extracts its children, +// and replaces it with a function call + {{define}} block. Returns false if no +// wrap block was found. +func transformOneWrap(body string, known map[string]bool, childIdx *int) (string, bool, error) { + loc := wrapRegex.FindStringIndex(body) if loc == nil { return body, false, nil } - match := openTagRegex.FindStringSubmatch(body[loc[0]:loc[1]]) + match := wrapRegex.FindStringSubmatch(body[loc[0]:loc[1]]) name := match[1] - propsStr := strings.TrimSpace(match[2]) if !known[name] { - if isPascalCase(name) { - return "", false, fmt.Errorf("unknown component <%s>: not imported", name) - } - return body, false, nil + return "", false, fmt.Errorf("unknown component %q in {{ wrap }}: not imported", name) } - // Find the matching closing tag - closeTag := fmt.Sprintf("", name) - closeIdx := strings.Index(body[loc[1]:], closeTag) - if closeIdx == -1 { - return "", false, fmt.Errorf("unclosed component tag <%s>", name) + // Find the end of the {{ wrap ... }} action (the closing }}) + wrapClose := findActionClose(body, loc[0]) + if wrapClose == -1 { + return "", false, fmt.Errorf("unclosed {{ wrap %s }}: missing }}", name) } - closeIdx += loc[1] - // Extract child content between open and close tags - childContent := body[loc[1]:closeIdx] + // Extract the arguments between the component name and }} + // body[loc[1]:wrapClose] contains everything after "wrap ComponentName " up to "}}" + argsStr := strings.TrimSpace(body[loc[1]:wrapClose]) + + // Find the matching {{ end }} using a state-aware scanner + endStart, endClose, err := findMatchingEnd(body, wrapClose+2) // +2 to skip past }} + if err != nil { + return "", false, fmt.Errorf("{{ wrap %s }}: %w", name, err) + } - dictCall := buildDictCall(propsStr) - childTemplateName := fmt.Sprintf("%s_children", strings.ToLower(name)) + // Extract child content between {{ wrap ... }} and {{ end }} + childContent := body[wrapClose+2 : endStart] + + childTemplateName := fmt.Sprintf("%s_children_%d", strings.ToLower(name), *childIdx) + *childIdx++ + + // Build the dict call. The user passes (dict ...) as argsStr. + // We need to inject "__children" into the dict arguments. + // Strip outer parens from the dict expression to get the inner args. + dictInner := argsStr + if strings.HasPrefix(dictInner, "(") && strings.HasSuffix(dictInner, ")") { + dictInner = dictInner[1 : len(dictInner)-1] + } + if dictInner == "" { + dictInner = "dict" + } replacement := fmt.Sprintf( `{{ __gastro_%s (%s "__children" (__gastro_render_children "%s" .)) }}`, - name, dictCall, childTemplateName, + name, dictInner, childTemplateName, ) - // Append a {{define}} block so the child content is available as a sub-template defineBlock := fmt.Sprintf( "\n{{define %q}}%s{{end}}", childTemplateName, childContent, ) - result := body[:loc[0]] + replacement + body[closeIdx+len(closeTag):] + defineBlock + result := body[:loc[0]] + replacement + body[endClose:] + defineBlock return result, true, nil } -// propRegex matches Key={.expr} or Key="literal" patterns -var propRegex = regexp.MustCompile(`(\w+)=(?:\{([^}]+)\}|"([^"]*)")`) +// findActionClose finds the position of the }} that closes the {{ action starting at pos. +// It skips over quoted strings inside the action. Returns the index of the first } of }}, +// or -1 if not found. +func findActionClose(body string, pos int) int { + i := pos + // Skip past {{ + for i < len(body)-1 { + if body[i] == '{' && body[i+1] == '{' { + i += 2 + break + } + i++ + } -// buildDictCall parses props and builds a Go template `dict` call. -// e.g. `Title={.Name} Urgent={.IsHot}` -> `dict "Title" .Name "Urgent" .IsHot` -func buildDictCall(propsStr string) string { - if strings.TrimSpace(propsStr) == "" { - return "dict" + for i < len(body)-1 { + switch body[i] { + case '"': + // Skip double-quoted string + i++ + for i < len(body) && body[i] != '"' { + if body[i] == '\\' { + i++ // skip escaped char + } + i++ + } + case '`': + // Skip raw string + i++ + for i < len(body) && body[i] != '`' { + i++ + } + case '}': + if i+1 < len(body) && body[i+1] == '}' { + return i + } + } + i++ } + return -1 +} + +// findMatchingEnd scans from startPos to find the {{ end }} that matches +// the current nesting depth. It correctly handles nested {{ if }}, {{ range }}, +// {{ with }}, {{ block }}, {{ define }}, and {{ wrap }} blocks, and skips +// over comments and string literals. +// +// Returns (endStart, endClose, error) where endStart is the position of the +// opening {{ of {{ end }}, and endClose is the position after the closing }}. +func findMatchingEnd(body string, startPos int) (int, int, error) { + depth := 1 + i := startPos + + for i < len(body)-1 { + // Skip non-action content + if body[i] != '{' || (i+1 < len(body) && body[i+1] != '{') { + i++ + continue + } + + // We found {{ — determine what kind of action it is + actionStart := i - matches := propRegex.FindAllStringSubmatch(propsStr, -1) - if len(matches) == 0 { - return "dict" + // Check for comment {{/* ... */}} + if i+3 < len(body) && body[i+2] == '/' && body[i+3] == '*' { + end := strings.Index(body[i:], "*/}}") + if end == -1 { + return -1, -1, fmt.Errorf("unclosed comment") + } + i += end + 4 + continue + } + + // Read the keyword of the action + keyword, actionEnd := readActionKeyword(body, i) + + switch keyword { + case "if", "range", "with", "block", "define", "wrap": + depth++ + case "end": + depth-- + if depth == 0 { + return actionStart, actionEnd, nil + } + } + + i = actionEnd + } + + return -1, -1, fmt.Errorf("missing {{ end }}") +} + +// readActionKeyword reads the first keyword from a {{ ... }} action starting at pos. +// Returns the keyword and the position after the closing }}. +func readActionKeyword(body string, pos int) (string, int) { + i := pos + 2 // skip {{ + + // Skip whitespace and optional leading dash ({{- ...) + for i < len(body) && (body[i] == ' ' || body[i] == '\t' || body[i] == '\n' || body[i] == '\r' || body[i] == '-') { + i++ } - var parts []string - parts = append(parts, "dict") - for _, m := range matches { - key := m[1] - if m[2] != "" { - // {.expr} form — wrap in parens if it contains a pipe - expr := m[2] - if strings.Contains(expr, "|") { - expr = "(" + expr + ")" + // Read keyword + start := i + for i < len(body) && isWordChar(body[i]) { + i++ + } + keyword := body[start:i] + + // Find the closing }} + for i < len(body)-1 { + switch body[i] { + case '"': + i++ + for i < len(body) && body[i] != '"' { + if body[i] == '\\' { + i++ + } + i++ + } + case '`': + i++ + for i < len(body) && body[i] != '`' { + i++ + } + case '}': + if i+1 < len(body) && body[i+1] == '}' { + return keyword, i + 2 } - parts = append(parts, fmt.Sprintf("%q %s", key, expr)) - } else { - // "literal" form - parts = append(parts, fmt.Sprintf("%q %q", key, m[3])) } + i++ } - return strings.Join(parts, " ") + return keyword, len(body) +} + +func isWordChar(c byte) bool { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' } -func isPascalCase(s string) bool { - if s == "" { - return false +// detectOldSyntax checks for old HTML-like component syntax and provides +// helpful migration errors. +func detectOldSyntax(body string, known map[string]bool) error { + m := oldPropSyntaxRegex.FindStringSubmatch(body) + if m != nil { + return fmt.Errorf("found old component syntax (e.g. %s): use {{ render X (dict ...) }} or {{ wrap X (dict ...) }}...{{ end }} instead", m[0]) } - return unicode.IsUpper(rune(s[0])) + + return nil } diff --git a/internal/codegen/template_test.go b/internal/codegen/template_test.go index 3fcebcd..719afb2 100644 --- a/internal/codegen/template_test.go +++ b/internal/codegen/template_test.go @@ -39,8 +39,8 @@ func TestTransformTemplate_PassthroughGoTemplateExpressions(t *testing.T) { } } -func TestTransformTemplate_SelfClosingComponent(t *testing.T) { - body := `` +func TestTransformTemplate_RenderLeafComponent(t *testing.T) { + body := `{{ render Card (dict "Title" .Name "Urgent" .IsHot) }}` uses := []parser.UseDeclaration{ {Name: "Card", Path: "components/card.gastro"}, } @@ -52,14 +52,14 @@ func TestTransformTemplate_SelfClosingComponent(t *testing.T) { want := `{{ __gastro_Card (dict "Title" .Name "Urgent" .IsHot) }}` if result != want { - t.Errorf("self-closing component:\ngot: %q\nwant: %q", result, want) + t.Errorf("render leaf:\ngot: %q\nwant: %q", result, want) } } -func TestTransformTemplate_ComponentWithStringLiteral(t *testing.T) { - body := `` +func TestTransformTemplate_RenderNoProps(t *testing.T) { + body := `{{ render Hero (dict) }}` uses := []parser.UseDeclaration{ - {Name: "Card", Path: "components/card.gastro"}, + {Name: "Hero", Path: "components/hero.gastro"}, } result, err := codegen.TransformTemplate(body, uses) @@ -67,16 +67,16 @@ func TestTransformTemplate_ComponentWithStringLiteral(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - want := `{{ __gastro_Card (dict "Title" "hello") }}` + want := `{{ __gastro_Hero (dict) }}` if result != want { - t.Errorf("string literal prop:\ngot: %q\nwant: %q", result, want) + t.Errorf("render no props:\ngot: %q\nwant: %q", result, want) } } -func TestTransformTemplate_ComponentWithChildren(t *testing.T) { - body := ` +func TestTransformTemplate_WrapWithChildren(t *testing.T) { + body := `{{ wrap Layout (dict "Title" .Title) }}

Hello

-
` +{{ end }}` uses := []parser.UseDeclaration{ {Name: "Layout", Path: "components/layout.gastro"}, } @@ -86,35 +86,33 @@ func TestTransformTemplate_ComponentWithChildren(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - // Children should be captured and passed as __children assertContains(t, result, `__gastro_Layout`) assertContains(t, result, `"Title" .Title`) assertContains(t, result, `__gastro_render_children`) - - // Child content should be extracted into a {{define}} block - assertContains(t, result, `{{define "layout_children"}}`) + assertContains(t, result, `{{define "layout_children_0"}}`) assertContains(t, result, `

Hello

`) assertContains(t, result, `{{end}}`) } -func TestTransformTemplate_SlotBecomesChildren(t *testing.T) { - body := `
- -
` +func TestTransformTemplate_WrapEmptyChildren(t *testing.T) { + body := `{{ wrap Layout (dict "Title" .Title) }}{{ end }}` + uses := []parser.UseDeclaration{ + {Name: "Layout", Path: "components/layout.gastro"}, + } - result, err := codegen.TransformTemplate(body, nil) + result, err := codegen.TransformTemplate(body, uses) if err != nil { t.Fatalf("unexpected error: %v", err) } - assertContains(t, result, `{{ .Children }}`) - assertNotContains(t, result, ``) + assertContains(t, result, `__gastro_Layout`) + assertContains(t, result, `{{define "layout_children_0"}}`) } func TestTransformTemplate_MixedHTMLAndComponents(t *testing.T) { body := `

{{ .Title }}

{{ range .Items }} - + {{ render Card (dict "Title" .Name) }} {{ end }}` uses := []parser.UseDeclaration{ {Name: "Card", Path: "components/card.gastro"}, @@ -125,29 +123,119 @@ func TestTransformTemplate_MixedHTMLAndComponents(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - // HTML and go template expressions should be unchanged assertContains(t, result, `

{{ .Title }}

`) assertContains(t, result, `{{ range .Items }}`) - assertContains(t, result, `{{ end }}`) + assertContains(t, result, `__gastro_Card`) + assertNotContains(t, result, `render Card`) +} + +func TestTransformTemplate_NestedWraps(t *testing.T) { + body := `{{ wrap A (dict) }}{{ wrap B (dict) }}inner{{ end }}{{ end }}` + uses := []parser.UseDeclaration{ + {Name: "A", Path: "components/a.gastro"}, + {Name: "B", Path: "components/b.gastro"}, + } + + result, err := codegen.TransformTemplate(body, uses) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - // Component should be transformed + assertContains(t, result, `__gastro_A`) + assertContains(t, result, `__gastro_B`) + assertContains(t, result, `{{define "a_children_0"}}`) + assertContains(t, result, `{{define "b_children_1"}}`) + assertContains(t, result, `inner`) +} + +func TestTransformTemplate_WrapWithInnerRange(t *testing.T) { + body := `{{ wrap Layout (dict "Title" .Title) }}{{ range .Items }}{{ render Card (dict "Title" .Name) }}{{ end }}{{ end }}` + uses := []parser.UseDeclaration{ + {Name: "Layout", Path: "components/layout.gastro"}, + {Name: "Card", Path: "components/card.gastro"}, + } + + result, err := codegen.TransformTemplate(body, uses) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertContains(t, result, `__gastro_Layout`) assertContains(t, result, `__gastro_Card`) - assertNotContains(t, result, `` +func TestTransformTemplate_UnknownComponentRender(t *testing.T) { + body := `{{ render Unknown (dict "Title" .Name) }}` _, err := codegen.TransformTemplate(body, nil) if err == nil { - t.Fatal("expected an error for unknown component, got nil") + t.Fatal("expected error for unknown component, got nil") } + assertContains(t, err.Error(), "Unknown") + assertContains(t, err.Error(), "not imported") } -func TestTransformTemplate_ComponentNoProps(t *testing.T) { - body := `
` +func TestTransformTemplate_UnknownComponentWrap(t *testing.T) { + body := `{{ wrap Unknown (dict) }}content{{ end }}` + + _, err := codegen.TransformTemplate(body, nil) + if err == nil { + t.Fatal("expected error for unknown component, got nil") + } + assertContains(t, err.Error(), "Unknown") +} + +func TestTransformTemplate_UnclosedWrap(t *testing.T) { + body := `{{ wrap Layout (dict) }}content` + uses := []parser.UseDeclaration{ + {Name: "Layout", Path: "components/layout.gastro"}, + } + + _, err := codegen.TransformTemplate(body, uses) + if err == nil { + t.Fatal("expected error for unclosed wrap, got nil") + } + assertContains(t, err.Error(), "missing {{ end }}") +} + +func TestTransformTemplate_CommentWithWrapInside(t *testing.T) { + body := `{{/* Example: {{ wrap Layout }} */}}{{ render Card (dict) }}` uses := []parser.UseDeclaration{ - {Name: "Header", Path: "components/header.gastro"}, + {Name: "Card", Path: "components/card.gastro"}, } result, err := codegen.TransformTemplate(body, uses) @@ -155,14 +243,12 @@ func TestTransformTemplate_ComponentNoProps(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - want := `{{ __gastro_Header (dict) }}` - if result != want { - t.Errorf("component with no props:\ngot: %q\nwant: %q", result, want) - } + assertContains(t, result, `__gastro_Card`) + assertContains(t, result, `{{/* Example: {{ wrap Layout }} */}}`) } -func TestTransformTemplate_PipeExpressionInProps(t *testing.T) { - body := `` +func TestTransformTemplate_RenderWithPipeline(t *testing.T) { + body := `{{ render Card (dict "Date" (.CreatedAt | timeFormat "Jan 2, 2006")) }}` uses := []parser.UseDeclaration{ {Name: "Card", Path: "components/card.gastro"}, } @@ -172,20 +258,44 @@ func TestTransformTemplate_PipeExpressionInProps(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - // Pipe expressions must be wrapped in parens to avoid precedence issues - want := `{{ __gastro_Card (dict "Title" .Name "Date" (.CreatedAt | timeFormat "Jan 2, 2006")) }}` + want := `{{ __gastro_Card (dict "Date" (.CreatedAt | timeFormat "Jan 2, 2006")) }}` if result != want { - t.Errorf("pipe expression in props:\ngot: %q\nwant: %q", result, want) + t.Errorf("render with pipeline:\ngot: %q\nwant: %q", result, want) } } -// TestTransformTemplate_OutputParseable verifies that transformed template -// output can be parsed by Go's text/template/parse package. The AST-based -// diagnostics depend on this. +func TestTransformTemplate_HTMLTagsPassThrough(t *testing.T) { + // PascalCase HTML tags that are NOT imported should pass through + // because with the new syntax, HTML is just HTML + body := `content` + + result, err := codegen.TransformTemplate(body, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result != body { + t.Errorf("HTML should pass through unchanged:\ngot: %q\nwant: %q", result, body) + } +} + +func TestTransformTemplate_OldPropSyntaxErrors(t *testing.T) { + body := `` + uses := []parser.UseDeclaration{ + {Name: "Card", Path: "components/card.gastro"}, + } + + _, err := codegen.TransformTemplate(body, uses) + if err == nil { + t.Fatal("expected error for old prop syntax") + } + assertContains(t, err.Error(), "old component syntax") +} + func TestTransformTemplate_OutputParseable(t *testing.T) { body := `

{{ .Title }}

{{ range .Items }} - + {{ render Card (dict "Title" .Name) }} {{ end }}` uses := []parser.UseDeclaration{ {Name: "Card", Path: "components/card.gastro"}, @@ -196,7 +306,6 @@ func TestTransformTemplate_OutputParseable(t *testing.T) { t.Fatalf("TransformTemplate error: %v", err) } - // Build a stub FuncMap with all default functions + component functions stubFuncs := make(map[string]any) for name := range gastro.DefaultFuncs() { stubFuncs[name] = "" @@ -208,7 +317,74 @@ func TestTransformTemplate_OutputParseable(t *testing.T) { trees, err := parse.Parse("test", result, "{{", "}}", stubFuncs) if err != nil { - t.Fatalf("transformed output is not parseable by text/template/parse: %v\noutput:\n%s", err, result) + t.Fatalf("transformed output is not parseable: %v\noutput:\n%s", err, result) + } + if trees["test"] == nil { + t.Fatal("expected parse tree for 'test', got nil") + } +} + +func TestTransformTemplate_WrapOutputParseable(t *testing.T) { + body := `{{ wrap Layout (dict "Title" .Title) }} +

Welcome

+ {{ range .Posts }} + {{ render Card (dict "Title" .Name) }} + {{ end }} +{{ end }}` + uses := []parser.UseDeclaration{ + {Name: "Layout", Path: "components/layout.gastro"}, + {Name: "Card", Path: "components/card.gastro"}, + } + + result, err := codegen.TransformTemplate(body, uses) + if err != nil { + t.Fatalf("TransformTemplate error: %v", err) + } + + stubFuncs := make(map[string]any) + for name := range gastro.DefaultFuncs() { + stubFuncs[name] = "" + } + for _, u := range uses { + stubFuncs["__gastro_"+u.Name] = "" + } + stubFuncs["__gastro_render_children"] = "" + + trees, err := parse.Parse("test", result, "{{", "}}", stubFuncs) + if err != nil { + t.Fatalf("output not parseable: %v\noutput:\n%s", err, result) + } + if trees["test"] == nil { + t.Fatal("expected parse tree for 'test', got nil") + } +} + +func TestTransformTemplate_DuplicateWrapParseable(t *testing.T) { + body := `{{ wrap Layout (dict "Title" "A") }}

One

{{ end }} +{{ wrap Layout (dict "Title" "B") }}

Two

{{ end }} +{{ render Card (dict "Title" .Name) }}` + uses := []parser.UseDeclaration{ + {Name: "Layout", Path: "components/layout.gastro"}, + {Name: "Card", Path: "components/card.gastro"}, + } + + result, err := codegen.TransformTemplate(body, uses) + if err != nil { + t.Fatalf("TransformTemplate error: %v", err) + } + + stubFuncs := make(map[string]any) + for name := range gastro.DefaultFuncs() { + stubFuncs[name] = "" + } + for _, u := range uses { + stubFuncs["__gastro_"+u.Name] = "" + } + stubFuncs["__gastro_render_children"] = "" + + trees, err := parse.Parse("test", result, "{{", "}}", stubFuncs) + if err != nil { + t.Fatalf("output not parseable: %v\noutput:\n%s", err, result) } if trees["test"] == nil { t.Fatal("expected parse tree for 'test', got nil") diff --git a/internal/compiler/testdata/basic/components/layout.gastro b/internal/compiler/testdata/basic/components/layout.gastro index 7ce7e33..8ce7af6 100644 --- a/internal/compiler/testdata/basic/components/layout.gastro +++ b/internal/compiler/testdata/basic/components/layout.gastro @@ -8,6 +8,6 @@ Title := gastro.Props().Title {{ .Title }} - + {{ .Children }} diff --git a/internal/compiler/testdata/basic/pages/index.gastro b/internal/compiler/testdata/basic/pages/index.gastro index 82050f5..ff4a019 100644 --- a/internal/compiler/testdata/basic/pages/index.gastro +++ b/internal/compiler/testdata/basic/pages/index.gastro @@ -4,6 +4,6 @@ import Layout "components/layout.gastro" ctx := gastro.Context() Title := "Home" --- - +{{ wrap Layout (dict "Title" .Title) }}

{{ .Title }}

-
+{{ end }} diff --git a/internal/compiler/testdata/composition/components/card.gastro b/internal/compiler/testdata/composition/components/card.gastro index 79e9353..4cc5708 100644 --- a/internal/compiler/testdata/composition/components/card.gastro +++ b/internal/compiler/testdata/composition/components/card.gastro @@ -11,5 +11,5 @@ Tag := gastro.Props().Tag ---

{{ .Title }}

- + {{ render Badge (dict "Label" .Tag) }}
diff --git a/internal/compiler/testdata/composition/pages/index.gastro b/internal/compiler/testdata/composition/pages/index.gastro index 0ea07d5..c0a92bc 100644 --- a/internal/compiler/testdata/composition/pages/index.gastro +++ b/internal/compiler/testdata/composition/pages/index.gastro @@ -4,4 +4,4 @@ import Card "components/card.gastro" ctx := gastro.Context() Title := "Composition Test" --- - +{{ render Card (dict "Title" .Title "Tag" "test") }} diff --git a/internal/lsp/template/completions.go b/internal/lsp/template/completions.go index a8d4b6d..1d7f839 100644 --- a/internal/lsp/template/completions.go +++ b/internal/lsp/template/completions.go @@ -200,7 +200,10 @@ func diagnoseDoubleDot(templateBody string) []Diagnostic { return diags } -// diagnoseUnknownComponents detects tags that are not imported. +// componentInvocationRegex matches {{ render X or {{ wrap X where X is PascalCase. +var componentInvocationRegex = regexp.MustCompile(`\{\{\s*(?:render|wrap)\s+([A-Z][a-zA-Z0-9]*)`) + +// diagnoseUnknownComponents detects {{ render X }} and {{ wrap X }} where X is not imported. func diagnoseUnknownComponents(templateBody string, uses []parser.UseDeclaration) []Diagnostic { knownComponents := make(map[string]bool, len(uses)) for _, u := range uses { @@ -208,8 +211,7 @@ func diagnoseUnknownComponents(templateBody string, uses []parser.UseDeclaration } var diags []Diagnostic - compRe := regexp.MustCompile(`<([A-Z][a-zA-Z0-9]*)[\s/>]`) - for _, idx := range compRe.FindAllStringSubmatchIndex(templateBody, -1) { + for _, idx := range componentInvocationRegex.FindAllStringSubmatchIndex(templateBody, -1) { compName := templateBody[idx[2]:idx[3]] if !knownComponents[compName] { startLine, startChar := OffsetToLineChar(templateBody, idx[2]) @@ -262,32 +264,28 @@ func ResolveComponentProps(projectDir, componentPath string, openDocs map[string return fields, nil } -// componentTagRegex matches self-closing and open component tags with their props. -var componentTagRegex = regexp.MustCompile(`<([A-Z][a-zA-Z0-9]*)((?:\s+\w+=(?:\{[^}]*\}|"[^"]*"))*)\s*/?>`) +// componentCallRegex matches {{ render X (dict "Key" ...) }} or {{ wrap X (dict "Key" ...) }} +// and captures the component name and the dict arguments. +var componentCallRegex = regexp.MustCompile(`\{\{\s*(?:render|wrap)\s+([A-Z][a-zA-Z0-9]*)\s+\(dict\b([^)]*)\)`) -// componentPropRegex matches Key={.expr} or Key="literal" patterns inside a tag. -var componentPropRegex = regexp.MustCompile(`(\w+)=(?:\{[^}]*\}|"[^"]*")`) +// dictKeyRegex matches string keys inside a dict call: "KeyName" +var dictKeyRegex = regexp.MustCompile(`"([A-Z][a-zA-Z0-9]*)"`) -// DiagnoseComponentProps checks that props passed to component tags match +// DiagnoseComponentProps checks that props passed to component calls match // the component's Props struct. Reports unknown props as errors and missing -// props as warnings. +// props as warnings. Props are detected in (dict "Key" value ...) syntax. func DiagnoseComponentProps(templateBody string, uses []parser.UseDeclaration, propsMap map[string][]codegen.StructField) []Diagnostic { if len(propsMap) == 0 { return nil } - usePaths := make(map[string]string, len(uses)) - for _, u := range uses { - usePaths[u.Name] = u.Path - } - var diags []Diagnostic - for _, idx := range componentTagRegex.FindAllStringSubmatchIndex(templateBody, -1) { + for _, idx := range componentCallRegex.FindAllStringSubmatchIndex(templateBody, -1) { compName := templateBody[idx[2]:idx[3]] - propsStr := "" + dictArgs := "" if idx[4] >= 0 && idx[5] >= 0 { - propsStr = strings.TrimSpace(templateBody[idx[4]:idx[5]]) + dictArgs = templateBody[idx[4]:idx[5]] } fields, ok := propsMap[compName] @@ -295,29 +293,22 @@ func DiagnoseComponentProps(templateBody string, uses []parser.UseDeclaration, p continue } - // Build set of known field names fieldNames := make(map[string]bool, len(fields)) for _, f := range fields { fieldNames[f.Name] = true } - // Extract provided prop names + // Extract provided prop names from dict keys providedProps := make(map[string]bool) - propMatches := componentPropRegex.FindAllStringSubmatch(propsStr, -1) - for _, m := range propMatches { + for _, m := range dictKeyRegex.FindAllStringSubmatch(dictArgs, -1) { providedProps[m[1]] = true } // Check for unknown props - for _, m := range componentPropRegex.FindAllStringSubmatchIndex(propsStr, -1) { - propName := propsStr[m[2]:m[3]] + for _, m := range dictKeyRegex.FindAllStringSubmatchIndex(dictArgs, -1) { + propName := dictArgs[m[2]:m[3]] if !fieldNames[propName] { - // Calculate position relative to the full template body propAbsOffset := idx[4] + m[2] - // Skip leading whitespace that was trimmed - if propsStr != templateBody[idx[4]:idx[5]] { - propAbsOffset = strings.Index(templateBody[idx[4]:idx[5]], propsStr) + idx[4] + m[2] - } startLine, startChar := OffsetToLineChar(templateBody, propAbsOffset) endLine, endChar := OffsetToLineChar(templateBody, propAbsOffset+len(propName)) @@ -330,7 +321,7 @@ func DiagnoseComponentProps(templateBody string, uses []parser.UseDeclaration, p StartChar: startChar, EndLine: endLine, EndChar: endChar, - Message: fmt.Sprintf("unknown prop %q on component <%s>; available: %s", propName, compName, strings.Join(available, ", ")), + Message: fmt.Sprintf("unknown prop %q on component %s; available: %s", propName, compName, strings.Join(available, ", ")), Severity: 1, }) } @@ -346,7 +337,7 @@ func DiagnoseComponentProps(templateBody string, uses []parser.UseDeclaration, p StartChar: tagStartChar, EndLine: tagEndLine, EndChar: tagEndChar, - Message: fmt.Sprintf("missing prop %q on component <%s>", f.Name, compName), + Message: fmt.Sprintf("missing prop %q on component %s", f.Name, compName), Severity: 2, }) } @@ -363,42 +354,26 @@ type ComponentTagContext struct { ExistingProps []string // prop names already specified on the tag } -// unclosedTagRegex matches an opening component tag up to where the cursor -// might be positioned (no closing > or />). -var unclosedTagRegex = regexp.MustCompile(`<([A-Z][a-zA-Z0-9]*)((?:\s+\w+=(?:\{[^}]*\}|"[^"]*"))*)\s*$`) +// unclosedComponentCallRegex matches {{ render X (dict ... or {{ wrap X (dict ... +// without a closing }} — indicating the cursor is inside the component call. +var unclosedComponentCallRegex = regexp.MustCompile(`\{\{\s*(?:render|wrap)\s+([A-Z][a-zA-Z0-9]*)\s+\(dict\b([^)]*?)$`) // DetectComponentTagContext determines if the cursor (given as a byte offset -// in the template body) is inside a component tag. Returns nil if the cursor -// is not inside a component tag. +// in the template body) is inside a component call ({{ render X (dict ...) }} +// or {{ wrap X (dict ...) }}). Returns nil if the cursor is not inside one. func DetectComponentTagContext(templateBody string, cursorOffset int, uses []parser.UseDeclaration) *ComponentTagContext { if cursorOffset < 0 || cursorOffset > len(templateBody) { return nil } - // Build set of known component names known := make(map[string]bool, len(uses)) for _, u := range uses { known[u.Name] = true } - // Take the text before the cursor and look for an unclosed component tag before := templateBody[:cursorOffset] - // Find the last `<` that starts a PascalCase tag - lastOpen := strings.LastIndex(before, "<") - if lastOpen < 0 { - return nil - } - - // Check for a closing `>` or `/>` between the tag open and cursor — - // if found, the tag is already closed and cursor is not inside it - afterOpen := before[lastOpen:] - if strings.Contains(afterOpen, "/>") || strings.ContainsRune(afterOpen, '>') { - return nil - } - - // Match the tag pattern - m := unclosedTagRegex.FindStringSubmatch(afterOpen) + m := unclosedComponentCallRegex.FindStringSubmatch(before) if m == nil { return nil } @@ -408,11 +383,10 @@ func DetectComponentTagContext(templateBody string, cursorOffset int, uses []par return nil } - // Extract already-specified props from the attributes string - attrsStr := m[2] + // Extract already-specified prop keys from the dict arguments + dictArgs := m[2] var existing []string - propRe := regexp.MustCompile(`(\w+)=`) - for _, pm := range propRe.FindAllStringSubmatch(attrsStr, -1) { + for _, pm := range dictKeyRegex.FindAllStringSubmatch(dictArgs, -1) { existing = append(existing, pm[1]) } @@ -423,14 +397,16 @@ func DetectComponentTagContext(templateBody string, cursorOffset int, uses []par } // PropValueContext is the result of detecting whether the cursor is inside a -// prop value expression ({...}) within a component tag. +// prop value expression within a component call's (dict ...) block. type PropValueContext struct { AfterPipe bool // true if cursor is after a | — suggest functions, not variables } -// DetectPropValueContext determines if the cursor is inside an incomplete -// prop value expression within a component tag. Returns nil if the cursor -// is not in a prop value context. +// DetectPropValueContext determines if the cursor is inside a prop value +// expression within a component call. With the new syntax, props are inside +// (dict "Key" .value ...) — the cursor is in a value position if it's +// inside a component call's dict and not immediately after a string key. +// Returns nil if the cursor is not in a prop value context. func DetectPropValueContext(templateBody string, cursorOffset int) *PropValueContext { if cursorOffset <= 0 || cursorOffset > len(templateBody) { return nil @@ -438,58 +414,26 @@ func DetectPropValueContext(templateBody string, cursorOffset int) *PropValueCon text := templateBody[:cursorOffset] - if !isInsideComponentTag(text) { - return nil - } - if !isInsideUnclosedBrace(text) { + // Check if we're inside a component call ({{ render/wrap X (dict ... ) + if !unclosedComponentCallRegex.MatchString(text) { return nil } + // Check if the cursor is after a pipe character (for function completions) return &PropValueContext{ - AfterPipe: isAfterPipeOutsideQuotes(text), + AfterPipe: isAfterPipeInDict(text), } } -// isInsideComponentTag returns true if the text ends inside an unclosed -// component tag (a or />). -func isInsideComponentTag(text string) bool { - lastOpen := strings.LastIndex(text, "<") - if lastOpen < 0 { - return false - } - - afterOpen := text[lastOpen:] - - // If there's a > or /> after the <, the tag is closed - if strings.Contains(afterOpen, "/>") || strings.ContainsRune(afterOpen, '>') { - return false - } - - // Check that the tag starts with a PascalCase name (component tag) - if len(afterOpen) < 2 { - return false - } - return afterOpen[1] >= 'A' && afterOpen[1] <= 'Z' -} - -// isInsideUnclosedBrace returns true if the text has an unclosed { — meaning -// the last { appears after the last }. -func isInsideUnclosedBrace(text string) bool { - lastOpen := strings.LastIndex(text, "{") - lastClose := strings.LastIndex(text, "}") - return lastOpen > lastClose -} - -// isAfterPipeOutsideQuotes returns true if the cursor (end of text) is after -// a | character that is not inside a quoted string. Scans from the last -// unclosed { to the end of text. -func isAfterPipeOutsideQuotes(text string) bool { - braceStart := strings.LastIndex(text, "{") - if braceStart < 0 { +// isAfterPipeInDict checks if the cursor is after a | inside a dict expression. +func isAfterPipeInDict(text string) bool { + // Find the last (dict in the text + dictStart := strings.LastIndex(text, "(dict") + if dictStart < 0 { return false } - expr := text[braceStart+1:] + expr := text[dictStart:] inQuote := false lastPipe := -1 @@ -504,8 +448,6 @@ func isAfterPipeOutsideQuotes(text string) bool { } } - // Cursor is after a pipe if the last unquoted | exists and there's - // only whitespace and identifier chars between it and the cursor return lastPipe >= 0 } @@ -525,7 +467,7 @@ func ComponentPropCompletions(fields []codegen.StructField, existingProps []stri items = append(items, CompletionItem{ Label: f.Name, Detail: f.Type, - InsertText: f.Name + `={.}`, + InsertText: `"` + f.Name + `" .`, }) } return items diff --git a/internal/lsp/template/completions_test.go b/internal/lsp/template/completions_test.go index e81bfa6..97eb5db 100644 --- a/internal/lsp/template/completions_test.go +++ b/internal/lsp/template/completions_test.go @@ -342,8 +342,8 @@ func TestDiagnostics_UnknownComponent(t *testing.T) { uses := []parser.UseDeclaration{ {Name: "Card", Path: "components/card.gastro"}, } - templateBody := ` -` + templateBody := `{{ render Card (dict "Title" .Name) }} +{{ render Unknown (dict) }}` diags := lsptemplate.Diagnose(templateBody, info, uses, nil, nil, nil) @@ -476,7 +476,7 @@ func TestDiagnoseComponentProps_UnknownProp(t *testing.T) { }, } - templateBody := `` + templateBody := `{{ render Card (dict "Title" .Name "Bogus" .X) }}` diags := lsptemplate.DiagnoseComponentProps(templateBody, uses, propsMap) found := false @@ -505,8 +505,7 @@ func TestDiagnoseComponentProps_MissingProp(t *testing.T) { }, } - // Only provide Title, missing Body - templateBody := `` + templateBody := `{{ render Card (dict "Title" .Name) }}` diags := lsptemplate.DiagnoseComponentProps(templateBody, uses, propsMap) found := false @@ -528,10 +527,9 @@ func TestDiagnoseComponentProps_NoPropsStruct(t *testing.T) { uses := []parser.UseDeclaration{ {Name: "Simple", Path: "components/simple.gastro"}, } - // Simple is not in propsMap — no Props struct propsMap := map[string][]codegen.StructField{} - templateBody := `` + templateBody := `{{ render Simple (dict) }}` diags := lsptemplate.DiagnoseComponentProps(templateBody, uses, propsMap) if len(diags) != 0 { @@ -549,14 +547,12 @@ func TestDiagnoseComponentProps_WithChildren(t *testing.T) { }, } - // Open tag with children — should check props on the open tag - templateBody := ` + templateBody := `{{ wrap Layout (dict "Title" .Title) }}

child content

-
` +{{ end }}` diags := lsptemplate.DiagnoseComponentProps(templateBody, uses, propsMap) - // Should not flag missing __children or any other internal prop for _, d := range diags { if strings.Contains(d.Message, "unknown prop") { t.Errorf("unexpected unknown prop diagnostic: %s", d.Message) @@ -572,71 +568,23 @@ func TestDetectPropValueContext(t *testing.T) { afterPipe bool }{ { - name: "simple variable in prop value", - input: `hello|

`, wantNil: true, }, { - name: "cursor after closed tag", - input: `|`, - wantNil: true, - }, - { - name: "multi-line tag", - input: "{{ .Title }}" + + result, err := parser.Parse("test.gastro", input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result.Imports) != 0 { + t.Errorf("expected 0 imports, got %d: %v", len(result.Imports), result.Imports) + } + if len(result.Uses) != 0 { + t.Errorf("expected 0 uses, got %d: %v", len(result.Uses), result.Uses) + } +} + +func TestParse_ImportInsideMultiLineBacktickString(t *testing.T) { + input := "---\nimport \"fmt\"\n\nExample := `\nimport Layout \"components/layout.gastro\"\nimport \"os\"\n`\nTitle := \"Hello\"\n---\n

{{ .Title }}

" + + result, err := parser.Parse("test.gastro", input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Only the real "fmt" import should be extracted + if len(result.Imports) != 1 { + t.Fatalf("expected 1 import, got %d: %v", len(result.Imports), result.Imports) + } + if result.Imports[0] != "fmt" { + t.Errorf("expected import \"fmt\", got %q", result.Imports[0]) + } + + // The component import inside the backtick string should NOT be extracted + if len(result.Uses) != 0 { + t.Errorf("expected 0 uses, got %d: %v", len(result.Uses), result.Uses) + } + + // The backtick string content should be preserved in frontmatter + if !strings.Contains(result.Frontmatter, `import Layout "components/layout.gastro"`) { + t.Error("backtick string content was corrupted by stripImports") + } +} + +func TestParse_GroupedImportInsideBacktickString(t *testing.T) { + input := "---\nimport \"fmt\"\n\nExample := `\nimport (\n\t\"os\"\n)\n`\nTitle := \"Hello\"\n---\n

{{ .Title }}

" + + result, err := parser.Parse("test.gastro", input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Only the real "fmt" import should be extracted, not the grouped import inside backtick + if len(result.Imports) != 1 { + t.Fatalf("expected 1 import, got %d: %v", len(result.Imports), result.Imports) + } + if result.Imports[0] != "fmt" { + t.Errorf("expected import \"fmt\", got %q", result.Imports[0]) + } + + // The grouped import inside the backtick string should be preserved in frontmatter + if !strings.Contains(result.Frontmatter, `import (`) { + t.Error("grouped import inside backtick string was stripped from frontmatter") + } +} + +func TestParse_RealImportsWithBacktickStrings(t *testing.T) { + input := "---\nimport (\n\t\"fmt\"\n\n\tLayout \"components/layout.gastro\"\n)\n\nExample := `\nimport Card \"components/card.gastro\"\n`\nTitle := \"Hello\"\n---\n

{{ .Title }}

" + + result, err := parser.Parse("test.gastro", input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Real imports should be extracted + if len(result.Imports) != 1 { + t.Fatalf("expected 1 import, got %d: %v", len(result.Imports), result.Imports) + } + if result.Imports[0] != "fmt" { + t.Errorf("expected import \"fmt\", got %q", result.Imports[0]) + } + + if len(result.Uses) != 1 { + t.Fatalf("expected 1 use, got %d: %v", len(result.Uses), result.Uses) + } + if result.Uses[0].Name != "Layout" { + t.Errorf("expected use Layout, got %q", result.Uses[0].Name) + } + + // The fake import inside backtick should be preserved in frontmatter + if !strings.Contains(result.Frontmatter, `import Card "components/card.gastro"`) { + t.Error("backtick string content was corrupted by stripImports") + } +} + func TestParse_TripleDashInsideStringLiteral(t *testing.T) { // --- inside a string literal in the frontmatter should NOT be // treated as a delimiter -- 2.51.2