Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions site/content/docs/troubleshooting/undefined-variable.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ Debug `UndefinedError` exceptions.
UndefinedError: Undefined variable 'usre' in page.html:5
```

Recent Kida diagnostics also carry structured fields for frameworks:

- `to_diagnostic().location` — template name, line, and optional column
- `to_diagnostic().source_snippet` — surrounding source lines
- `to_diagnostic().hints` — ordered next actions
- `to_diagnostic().template_stack` and `.component_stack` — render path context

## Common Causes

:::{dropdown} Typo in variable name
Expand Down Expand Up @@ -73,6 +80,22 @@ Ensure all template variables are passed in `render()`.
Verify object attributes match your code.
:::

:::{dropdown} Imported component slot uses a missing caller value
:icon: layers

```kida
{% from "components/card.html" import card %}

{% call card() %}
{{ missing_in_slot }}
{% end %}
```

Kida reports this against the caller template line that owns the slot body, not
the imported component file. Use the component stack to see which component was
rendering when the slot failed.
:::

:::{dropdown} Nested object is None or missing
:icon: layers

Expand All @@ -94,6 +117,9 @@ Under strict mode (the default), `{% if page.parent %}` alone raises if `parent`

## Solutions

Hints are ordered by confidence. A close typo match appears first; optional
value patterns such as `default(...)`, `??`, or null-safe access follow.

### Use default Filter

```kida
Expand Down
35 changes: 32 additions & 3 deletions site/content/docs/usage/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,23 @@ The compact format includes:
- **Hint** with suggestions (typo corrections, `default` filter usage)
- **Docs link** to the relevant error code documentation

This is the recommended format for frameworks that wrap Kida errors (like Chirp and Bengal).
Use `format_compact()` for terminal output and logs. Framework debug pages should
prefer `UndefinedError.to_diagnostic()` so they can render the same code,
location, source snippet, hints, documentation URL, template stack, and component
stack without parsing terminal text.

```python
from kida import UndefinedError

try:
template.render()
except UndefinedError as exc:
diagnostic = exc.to_diagnostic()
html = diagnostic.format_html_page()
```

The diagnostic payload stores plain strings. HTML, Markdown, and terminal
escaping happen only in the final renderer.

## Source Snippets

Expand All @@ -283,7 +299,8 @@ except UndefinedError as e:
if snippet:
print(snippet.lines) # List of (line_number, line_text) tuples
print(snippet.error_line) # The line number with the error
print(snippet.filename) # Template filename
print(e.template) # Template name
print(e.lineno) # Error line
```

You can also build snippets manually with `build_source_snippet()`:
Expand All @@ -307,11 +324,23 @@ Fix: Check for typos, ensure variable is passed to render().
### Attribute Error

```
UndefinedError: 'dict' object has no attribute 'nmae'
UndefinedError: Undefined attribute/key 'User.nmae' in page.html:5
```

Fix: Check attribute spelling, verify object type.

## Reading Template and Component Stacks

`template_stack` explains how rendering reached another template through
`include`, `extends`, or imported macros. `component_stack` explains which
`{% def %}` components were active. In an HTML error view, show the source
location first, then these stacks as supporting context. Python traceback frames
from generated code are secondary.

For imported components with slots, Kida reports slot-body errors against the
caller template while still keeping the imported component path in the component
stack.

### Type Error in Filter

```
Expand Down
24 changes: 24 additions & 0 deletions site/content/docs/usage/framework-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,30 @@ class KidaAdapter:

[Chirp](https://github.com/lbliii/chirp) uses this pattern in `KidaAdapter`.

## Framework Error Views

Frameworks should render Kida diagnostics from structured exception data, not by
scraping `str(exc)` or terminal-oriented `format_compact()` output.

```python
from kida import TemplateError, UndefinedError

try:
html = template.render(context)
except UndefinedError as exc:
diagnostic = exc.to_diagnostic()
html = diagnostic.format_html_page()
except TemplateError as exc:
html = f"<pre>{exc.format_compact()}</pre>"
```

For `UndefinedError`, the diagnostic payload includes the error code, kind,
missing name, template location, source snippet, ordered hints, documentation
URL, template stack, and component stack. The built-in HTML and Markdown
renderers escape source text and names at the final surface. If a framework
builds its own page, it should keep the Kida template location as authoritative
and collapse generated Python traceback frames by default.

## Case Studies

### Bengal (Static Site Generator)
Expand Down
13 changes: 12 additions & 1 deletion src/kida/compiler/coalescing.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ def _compile_node(self, node: Node) -> list[ast.stmt]: ...
# From Compiler core
def _emit_output(self, value_expr: ast.expr) -> ast.stmt: ...

def _make_line_marker(self, lineno: int) -> ast.stmt: ...

# From BasicStatementMixin
@staticmethod
def _expr_may_produce_none(node: Expr) -> bool: ...
Expand Down Expand Up @@ -308,7 +310,16 @@ def _compile_body_with_coalescing(self, nodes: list[Any]) -> list[ast.stmt]:
i += 1

if len(coalesceable) >= COALESCE_MIN_NODES:
# Generate single f-string append
# Generate single f-string append, preserving the first risky
# output line for UndefinedError/source-snippet attribution.
from kida.nodes import Output

first_output = next(
(node for node in coalesceable if isinstance(node, Output)),
None,
)
if first_output is not None:
stmts.append(self._make_line_marker(first_output.lineno))
stmts.append(self._compile_coalesced_output(coalesceable))
elif coalesceable:
# Single node - use normal compilation
Expand Down
182 changes: 164 additions & 18 deletions src/kida/compiler/statements/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,45 @@ def _def_name(arg1, arg2=default, *, _caller=None, _outer_ctx=ctx):
)

# Component call stack: push frame on entry (Sprint 1.3)
# _rc.component_stack.append((_rc.template_name or '', _rc.line, 'def_name'))
# Imported macro wrappers set component_call_* so this frame points to
# the caller site while template_name/source still point at the def.
component_template_expr = ast.BoolOp(
op=ast.Or(),
values=[
ast.Attribute(
value=ast.Name(id="_rc", ctx=ast.Load()),
attr="component_call_template",
ctx=ast.Load(),
),
ast.Attribute(
value=ast.Name(id="_rc", ctx=ast.Load()),
attr="template_name",
ctx=ast.Load(),
),
ast.Constant(value=""),
],
)
component_line_expr = ast.IfExp(
test=ast.Compare(
left=ast.Attribute(
value=ast.Name(id="_rc", ctx=ast.Load()),
attr="component_call_line",
ctx=ast.Load(),
),
ops=[ast.IsNot()],
comparators=[ast.Constant(value=None)],
),
body=ast.Attribute(
value=ast.Name(id="_rc", ctx=ast.Load()),
attr="component_call_line",
ctx=ast.Load(),
),
orelse=ast.Attribute(
value=ast.Name(id="_rc", ctx=ast.Load()),
attr="line",
ctx=ast.Load(),
),
)
func_body.append(
ast.Expr(
value=ast.Call(
Expand All @@ -317,22 +355,8 @@ def _def_name(arg1, arg2=default, *, _caller=None, _outer_ctx=ctx):
args=[
ast.Tuple(
elts=[
ast.BoolOp(
op=ast.Or(),
values=[
ast.Attribute(
value=ast.Name(id="_rc", ctx=ast.Load()),
attr="template_name",
ctx=ast.Load(),
),
ast.Constant(value=""),
],
),
ast.Attribute(
value=ast.Name(id="_rc", ctx=ast.Load()),
attr="line",
ctx=ast.Load(),
),
component_template_expr,
component_line_expr,
ast.Constant(value=def_name),
],
ctx=ast.Load(),
Expand Down Expand Up @@ -632,7 +656,7 @@ def _compile_call_block(self, node: CallBlock) -> list[ast.stmt]:
# def _caller_wrapper(_scope_stack, slot="default", **_slot_kwargs):
# f = _caller_slots.get(slot)
# return f(_scope_stack, **_slot_kwargs) if f else _Markup("")
wrapper_body: list[ast.stmt] = [
wrapper_call_body: list[ast.stmt] = [
ast.Assign(
targets=[ast.Name(id="_f", ctx=ast.Store())],
value=ast.Call(
Expand Down Expand Up @@ -666,6 +690,113 @@ def _compile_call_block(self, node: CallBlock) -> list[ast.stmt]:
),
),
]
wrapper_body: list[ast.stmt] = [
ast.Assign(
targets=[ast.Name(id="_rc", ctx=ast.Store())],
value=ast.BoolOp(
op=ast.Or(),
values=[
ast.Call(
func=ast.Name(id="_get_render_ctx", ctx=ast.Load()),
args=[],
keywords=[],
),
ast.Name(id="_null_rc", ctx=ast.Load()),
],
),
),
ast.Assign(
targets=[ast.Name(id="_prev_template_name", ctx=ast.Store())],
value=ast.Attribute(
value=ast.Name(id="_rc", ctx=ast.Load()),
attr="template_name",
ctx=ast.Load(),
),
),
ast.Assign(
targets=[ast.Name(id="_prev_source", ctx=ast.Store())],
value=ast.Attribute(
value=ast.Name(id="_rc", ctx=ast.Load()),
attr="source",
ctx=ast.Load(),
),
),
ast.Assign(
targets=[ast.Name(id="_prev_line", ctx=ast.Store())],
value=ast.Attribute(
value=ast.Name(id="_rc", ctx=ast.Load()),
attr="line",
ctx=ast.Load(),
),
),
ast.Assign(
targets=[
ast.Attribute(
value=ast.Name(id="_rc", ctx=ast.Load()),
attr="template_name",
ctx=ast.Store(),
)
],
value=ast.Name(id="_caller_template_name", ctx=ast.Load()),
),
ast.Assign(
targets=[
ast.Attribute(
value=ast.Name(id="_rc", ctx=ast.Load()),
attr="source",
ctx=ast.Store(),
)
],
value=ast.Name(id="_caller_source", ctx=ast.Load()),
),
ast.Assign(
targets=[
ast.Attribute(
value=ast.Name(id="_rc", ctx=ast.Load()),
attr="line",
ctx=ast.Store(),
)
],
value=ast.Name(id="_caller_line", ctx=ast.Load()),
),
Comment on lines +693 to +761
ast.Try(
body=wrapper_call_body,
handlers=[],
orelse=[],
finalbody=[
ast.Assign(
targets=[
ast.Attribute(
value=ast.Name(id="_rc", ctx=ast.Load()),
attr="template_name",
ctx=ast.Store(),
)
],
value=ast.Name(id="_prev_template_name", ctx=ast.Load()),
),
ast.Assign(
targets=[
ast.Attribute(
value=ast.Name(id="_rc", ctx=ast.Load()),
attr="source",
ctx=ast.Store(),
)
],
value=ast.Name(id="_prev_source", ctx=ast.Load()),
),
ast.Assign(
targets=[
ast.Attribute(
value=ast.Name(id="_rc", ctx=ast.Load()),
attr="line",
ctx=ast.Store(),
)
],
value=ast.Name(id="_prev_line", ctx=ast.Load()),
),
],
),
]
stmts.append(
ast.Assign(
targets=[ast.Name(id="_caller_slots", ctx=ast.Store())],
Expand All @@ -675,6 +806,21 @@ def _compile_call_block(self, node: CallBlock) -> list[ast.stmt]:
),
)
)
for attr, local_name in (
("template_name", "_caller_template_name"),
("source", "_caller_source"),
("line", "_caller_line"),
):
stmts.append(
ast.Assign(
targets=[ast.Name(id=local_name, ctx=ast.Store())],
value=ast.Attribute(
value=ast.Name(id="_rc", ctx=ast.Load()),
attr=attr,
ctx=ast.Load(),
),
)
)
# Use _caller_wrapper to avoid shadowing the def's _caller parameter
# (which would cause UnboundLocalError when _def_caller = _caller runs)
# Wrapper takes (_scope_stack, slot, **_slot_kwargs) so slot functions get
Expand Down
Loading
Loading