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(`?([A-Z][a-zA-Z0-9]*)`)
+// componentNameRegex matches component names after render/wrap keywords in {{ }} blocks.
+var componentNameRegex = regexp.MustCompile(`\{\{\s*(?:render|wrap)\s+([A-Z][a-zA-Z0-9]*)`)
-// componentHover checks if the cursor is on a component tag name and returns
-// hover information showing the component's Props struct fields.
+// componentHover checks if the cursor is on a component name after render/wrap
+// in a {{ }} block and returns hover information showing the component's Props
+// struct fields.
func (s *server) componentHover(parsed *parser.File, cursorOffset int) any {
body := parsed.TemplateBody
- for _, idx := range componentTagNameRegex.FindAllStringSubmatchIndex(body, -1) {
+ for _, idx := range componentNameRegex.FindAllStringSubmatchIndex(body, -1) {
nameStart, nameEnd := idx[2], idx[3]
if cursorOffset < nameStart || cursorOffset > 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