This document captures key learnings and patterns for creating documentation pages in the Spry demo site.
Documentation pages extend PageComponent (not Component). PageComponent is special because it acts as both a Component AND an Endpoint, meaning it handles its own route.
public class Demo.Pages.ComponentsOverviewPage : PageComponent {
public override string markup { get {
return """...""";
}}
public override async void prepare() throws Error {
// Setup code
}
}
IMPORTANT: Page markup should NOT include the full HTML structure (<!DOCTYPE html>, <html>, <head>, <body>). The MainTemplate class automatically wraps page content with the proper HTML structure, including:
<head> with stylesheets and scripts<body> with the page containerYour page markup should start with the content container:
public override string markup { get {
return """
<div class="page-container">
<spry-component name="NavSidebarComponent" sid="nav"/>
<main class="main-content">
<!-- Your content here -->
</main>
</div>
""";
}}
Every documentation page should include the NavSidebarComponent and set its current_path property in prepare():
public override async void prepare() throws Error {
var nav = get_component_child<NavSidebarComponent>("nav");
nav.current_path = "/components/overview";
}
using Spry;
using Astralis; // Required for HttpContext
using Inversion; // Required for inject<T>()
using Invercargill.DataStructures; // Required for Series<T>
CRITICAL: Vala's triple-quoted strings (""") CANNOT contain another triple-quoted string sequence. This means you cannot embed code examples that show triple-quoted strings directly.
Wrong (will fail to compile):
// This breaks because """ appears inside """
return """
<pre><code>
public override string markup { get {
return """<div>content</div>"""; // SYNTAX ERROR!
}}
</code></pre>
""";
Solution: Use regular strings with \n for line breaks and string concatenation:
private const string COMPONENT_CODE =
"public class MyComponent : Component {\n" +
" public override string markup { get {\n" +
" return \"\"\"<div>content</div>\"\"\";\n" +
" }}\n" +
"}";
When code contains double quotes, you need to escape them:
\" for literal quotesIn regular strings: Use \\\" (escaped backslash + escaped quote)
// Inside a triple-quoted string:
"this[\"element\"].text_content = \"Hello\";"
// Inside a regular string (more escaping needed):
"this[\\\"element\\\"].text_content = \\\"Hello\\\";"
JSON strings in code examples need heavy escaping:
// Showing hx-vals in code:
"this[\"button\"].set_attribute(\"hx-vals\", \"{\\\"id\\\": 123}\");"
Use CodeBlockComponent for displaying code examples with syntax highlighting:
// In markup:
"<spry-component name=\"CodeBlockComponent\" sid=\"example-code\"/>"
// In prepare():
var code_block = get_component_child<CodeBlockComponent>("example-code");
code_block.language = "vala";
code_block.code = YOUR_CODE_STRING;
Supported languages: vala, xml, css, javascript
Use DemoHostComponent for interactive demos that show source code alongside a working demo:
// In markup:
"<spry-component name=\"DemoHostComponent\" sid=\"demo\"/>"
// In prepare():
var demo = get_component_child<DemoHostComponent>("demo");
demo.source_file = "DemoComponents/SimpleCounterDemo.vala";
// Create and set the actual demo component
var counter = factory.create<SimpleCounterDemo>();
demo.set_outlet_child("demo-outlet", counter);
The DemoHostComponent provides:
IMPORTANT: Components are instantiated fresh on each request. You cannot store state directly in component instance fields between requests.
Wrong (state won't persist):
public class MyComponent : Component {
private int counter = 0; // Reset to 0 on every request!
}
Correct (use a singleton store):
public class MyComponent : Component {
private MyStore store = inject<MyStore>(); // Singleton, persists
}
public class MyStore : Object {
public int counter { get; set; default = 0; } // Persists between requests
}
The prepare() method must be marked async:
public override async void prepare() throws Error {
// ...
}
Use the inject<T>() pattern for dependency injection:
private ComponentFactory factory = inject<ComponentFactory>();
private HttpContext http_context = inject<HttpContext>();
private MyStore store = inject<MyStore>(); // Must be registered in Main.vala
Register singleton stores in demo/Main.vala:
// In Main.vala configure() method:
container.register<MyStore>(new MyStore());
The Series<T> type from Invercargill is used for collections:
public Series<TodoItem> items { get; set; default = new Series<TodoItem>(); }
// Add items:
items.add(new TodoItem(1, "Task"));
// Iterate:
foreach (var item in items) {
// ...
}
// Get count:
int count = items.length; // NOT .size!
// Create new series:
var new_items = new Series<TodoItem>();
// Select by sid:
this["my-element"].text_content = "Hello";
this["my-element"].add_class("active");
this["my-element"].set_attribute("data-id", "123");
// Set inner HTML:
this["container"].inner_html = "<span>Dynamic content</span>";
public async override void handle_action(string action) throws Error {
switch (action) {
case "Save":
// Get form data
var value = http_context.request.query_params.get_any_or_default("field_name");
// Process...
break;
case "Delete":
// ...
break;
}
}
// In markup:
"<ul><spry-outlet sid=\"items-outlet\"/></ul>"
// In prepare():
var children = new Series<Renderable>();
foreach (var item in store.items) {
var component = factory.create<ChildComponent>();
component.item_id = item.id;
children.add(component);
}
set_outlet_children("items-outlet", children);
Routes follow the pattern /section/page-name:
/components/overview/components/actions/components/outletsPages are registered with the router:
// In Main.vala:
router.add_route("/components/overview", typeof(ComponentsOverviewPage));
Components used in demos must be registered with the factory:
factory.register<SimpleCounterDemo>();
factory.register<TodoListDemo>();
demo/
├── Pages/ # PageComponent classes
│ ├── ComponentsOverviewPage.vala
│ ├── ComponentsActionsPage.vala
│ └── ...
├── Components/ # Shared UI components
│ ├── NavSidebarComponent.vala
│ ├── CodeBlockComponent.vala
│ └── DemoHostComponent.vala
├── DemoComponents/ # Demo-specific components
│ ├── SimpleCounterDemo.vala
│ ├── TodoListDemo.vala
│ └── ProgressDemo.vala
├── Static/
│ └── docs.css # Documentation styles
├── Main.vala # App configuration and routing
├── MainTemplate.vala # HTML wrapper template
└── meson.build # Build configuration
Add new source files to demo/meson.build:
demo_sources = files(
'Main.vala',
'MainTemplate.vala',
'Pages/ComponentsOverviewPage.vala',
'Pages/ComponentsActionsPage.vala',
'DemoComponents/SimpleCounterDemo.vala',
# ... etc
)
Documentation pages use CSS classes from docs.css:
.doc-hero - Hero section with title.doc-section - Content sections.doc-card - Card containers.doc-table - Tables for reference.doc-example - Code example containers.demo-container - Interactive demo containersusing Inversion;
using Astralis;set; to property definitionprepare() is asyncusing Spry;
using Astralis;
using Inversion;
public class Demo.Pages.YourPage : PageComponent {
private ComponentFactory factory = inject<ComponentFactory>();
public override string markup { get {
return """
<div class="page-container">
<spry-component name="NavSidebarComponent" sid="nav"/>
<main class="main-content">
<div class="doc-hero">
<h1>Your Page Title</h1>
<p class="doc-subtitle">Brief description</p>
</div>
<div class="doc-section">
<h2>Section Title</h2>
<p>Content...</p>
<spry-component name="CodeBlockComponent" sid="example"/>
</div>
</main>
</div>
""";
}}
public override async void prepare() throws Error {
var nav = get_component_child<NavSidebarComponent>("nav");
nav.current_path = "/your/path";
var code = get_component_child<CodeBlockComponent>("example");
code.language = "vala";
code.code = "your code here";
}
}