This document covers creating, configuring, and managing declarative .page files.
- Page Structure
- Page File Format
- PageManager Setup
- Resource Loading
- Virtual Resources
- Hot Reload
- Navigation
- Page Caching
A .page file consists of two main sections:
<view>
<!-- View definition -->
<title>Page Title</title>
<message>Message content</message>
<components>
<!-- UI components -->
</components>
</view>
<script>
// JavaScript logic
</script>The <view> element defines the page content:
| Element | Description |
|---|---|
<title> |
Page title (displayed at top of message) |
<message> |
Message body content |
<components> |
Container for UI buttons/controls |
| Attribute | Type | Default | Description |
|---|---|---|---|
id |
string | required | Unique page identifier |
vmodel |
string | - | ViewModel class name |
vmodel-props |
JSON | - | JSON object passed to ViewModel |
resource |
string | - | External resource path for page content |
web-preview |
boolean | true |
Enable link preview in messages |
back-title |
string | "◀ {{ parent.title }}" |
Custom back button template |
back-to-parent |
boolean | true |
Show back button to parent page |
max-items |
number | - | Auto-pagination: max items per page |
max-rows |
number | - | Auto-pagination: max rows per page |
| Attribute | Type | Description |
|---|---|---|
photo |
string | Path to photo resource or file ID |
document |
string | Path to document resource or file ID |
audio |
string | Path to audio resource or file ID |
video |
string | Path to video resource or file ID |
The <script> element contains JavaScript for:
- Lifecycle hooks (
onMounted,onUnmounted, etc.) - Event handlers
- Helper functions
- Data processing
<view>
<title>Welcome</title>
<message>Hello! Welcome to our bot.</message>
<components>
<command title="Start" @click="start()" />
<open title="Help" target="help" />
</components>
</view>
<script>
function start() {
UI.navigate('main-menu');
}
</script>Note: HTML mode is used by default. Use
<br/>for line breaks or<p>content</p>for paragraphs.
<view vmodel="CounterViewModel" vmodel-props='{"initialCount": 0}'>
<title>Counter</title>
<message>Current count: {{ VModel.Count }}<br/>Status: {{ VModel.GetStatus() }}</message>
<components>
<row>
<command title="-" @click="decrement()" />
<command title="{{ VModel.Count }}" @click="reset()" />
<command title="+" @click="increment()" />
</row>
</components>
</view>
<script>
function increment() {
VModel.Increment();
UI.refresh();
}
function decrement() {
VModel.Decrement();
UI.refresh();
}
function reset() {
VModel.Reset();
UI.refresh();
}
</script><view photo="backgrounds/welcome.png">
<title>Photo Demo</title>
<message>This message includes a photo.</message>
<components>
<command title="Next" @click="UI.navigate('next')" />
</components>
</view><view web-preview="true">
<title>Link Preview</title>
<message>Check out this article: https://example.com/article<br/><br/>The link above will show a preview.</message>
</view><view document="files/manual.pdf">
<title>User Manual</title>
<message>Here is the user manual.</message>
<components>
<command title="Back" @click="UI.back()" />
</components>
</view>Use v-if, v-else-if, and v-else on <message> elements for conditional content:
<view vmodel="StatusViewModel">
<title>Status</title>
<message v-if="VModel.Status === 'loading'">Loading data...</message>
<message v-else-if="VModel.Status === 'error'">Error occurred. Please try again.</message>
<message v-else>Data loaded successfully!</message>
<components>
<command title="Refresh" @click="refresh()" />
</components>
</view>
<script>
function refresh() {
VModel.LoadData();
UI.refresh();
}
</script>| Attribute | Type | Default | Description |
|---|---|---|---|
resource |
string | - | Load message content from external file |
md |
boolean | false |
Enable Markdown parsing |
pre |
boolean | false |
Preserve whitespace and newlines |
v-if |
expression | - | Conditional rendering condition |
v-else-if |
expression | - | Alternative condition |
v-else |
- | - | Default when all conditions are false |
// Path to pages directory
var pagesPath = Path.Combine("Resources", "Pages");
// Assembly containing ViewModel classes
var vmodelAssembly = typeof(MyViewModel).Assembly;
// Create PageManager
var pageManager = new PageManager(pagesPath, vmodelAssembly);
// Load all pages from directory
pageManager.LoadAll();
// Log loaded pages
Console.WriteLine($"Loaded {pageManager.PageCount} pages");
Console.WriteLine($"Pages: {string.Join(", ", pageManager.GetPageIds())}");Resources/
└── Pages/
├── home.page
├── settings.page
├── help.page
└── admin/
├── dashboard.page
└── users.page
Page IDs are derived from file paths:
home.page→homesettings.page→settingsadmin/dashboard.page→admin/dashboard
// Get page for a specific user
var page = pageManager.GetPage("settings", botUser);
// Send the page
await page.SendPageAsync();public interface IResourceLoader {
string? BasePath { get; }
string? ResolvePath(string name);
byte[] GetBytes(string name);
string GetText(string name);
bool Exists(string name);
void ClearCache();
void ClearCache(string name);
}var resourceLoader = new ResourceLoader("Resources");
// Use with bot worker
var bot = new BotWorkerPulling<MyBotUser>(...) {
resourceLoader = resourceLoader,
// ...
};<!-- Photo from resources -->
<view photo="images/banner.png">
...
</view>Resources can be specified with different prefixes:
Absolute paths (from resource base):
<view photo="images/banner.png">
<!-- Resolves to: Resources/images/banner.png -->
</view>Page-relative paths:
<!-- Using @/ prefix - relative to page file directory -->
<view photo="@/images/banner.png">
<!-- If page is at Pages/admin/dashboard.page -->
<!-- Resolves to: Pages/admin/images/banner.png -->
</view>
<!-- Using ./ prefix - current directory -->
<view photo="./banner.png">
<!-- If page is at Pages/admin/dashboard.page -->
<!-- Resolves to: Pages/admin/banner.png -->
</view>
<!-- Using ../ prefix - parent directory -->
<view photo="../images/shared.png">
<!-- If page is at Pages/admin/dashboard.page -->
<!-- Resolves to: Pages/images/shared.png -->
</view>In message resource loading:
<view>
<!-- Absolute from base -->
<message resource="texts/welcome.md" />
<!-- Relative to page -->
<message resource="@/texts/welcome.md" />
</view>Implement IResourceLoader for custom resource sources (database, cloud storage, etc.).
public class DatabaseResourceLoader : IResourceLoader {
private readonly DatabaseContext _db;
private readonly Dictionary<string, byte[]> _cache = new();
public string? BasePath => null;
public DatabaseResourceLoader(DatabaseContext db) {
_db = db;
}
public byte[] GetBytes(string name) {
if (_cache.TryGetValue(name, out var cached)) {
return cached;
}
var resource = _db.Resources.FirstOrDefault(r => r.Path == name);
if (resource == null) {
throw new FileNotFoundException($"Resource not found: {name}");
}
_cache[name] = resource.Data;
return resource.Data;
}
public string GetText(string name) {
var bytes = GetBytes(name);
return Encoding.UTF8.GetString(bytes);
}
public bool Exists(string name) {
return _cache.ContainsKey(name) ||
_db.Resources.Any(r => r.Path == name);
}
public string? ResolvePath(string name) {
return Exists(name) ? name : null;
}
public void ClearCache() {
_cache.Clear();
}
public void ClearCache(string name) {
_cache.Remove(name);
}
}var dbResourceLoader = new DatabaseResourceLoader(dbContext);
var bot = new BotWorkerPulling<MyBotUser>(...) {
resourceLoader = dbResourceLoader,
// ...
};Combine file system and database:
public class HybridResourceLoader : IResourceLoader {
private readonly ResourceLoader _fileLoader;
private readonly DatabaseResourceLoader _dbLoader;
public HybridResourceLoader(string basePath, DatabaseContext db) {
_fileLoader = new ResourceLoader(basePath);
_dbLoader = new DatabaseResourceLoader(db);
}
public byte[] GetBytes(string name) {
// Try file system first
if (_fileLoader.Exists(name)) {
return _fileLoader.GetBytes(name);
}
// Fall back to database
return _dbLoader.GetBytes(name);
}
// ... other methods
}Page definitions are loaded at startup. To see changes during development:
// In BotUser - clear page cache to force reload
public void ClearPageCache() {
foreach (var page in pageCache.Values) {
page.Dispose();
}
pageCache.Clear();
}- Edit
.pagefiles - Send
/resetcommand in bot to clear user's page cache - Navigate to page again - fresh definition will be loaded
Add a command to clear the page cache during development:
public override async Task HandleCommandAsync(string cmd, string[] arguments, Message message) {
switch (cmd) {
case "reset":
ClearPageCache();
await SendTextMessageAsync("Page cache cleared. Use /start to begin fresh.");
break;
// ... other commands
}
}// Navigate using cached page (preserves state)
UI.navigate('settings');
// Navigate with fresh page instance
UI.navigateFresh('settings');
// Navigate as main page (clears navigation history)
UI.navigate('home', false);
// Send new message (doesn't replace current)
UI.sendPage('confirmation');
// Go back to parent page
UI.back();
// Close current page
UI.close();Sub-Page (default):
- Preserves parent page reference
UI.back()returns to parent- User can navigate back
Main Page:
- Clears navigation history
UI.back()does nothing- Fresh start
// Sub-page navigation
UI.navigate('details'); // Can go back
UI.navigate('details', true); // Explicit sub-page
// Main page navigation
UI.navigate('home', false); // No back navigation// In BotUser command handler
public override async Task HandleCommandAsync(string cmd, string[] arguments, Message message) {
switch (cmd) {
case "start":
var page = pageManager.GetPage("home", this);
if (page != null) {
await page.SendPageAsync();
}
break;
}
}Cache pages per user to preserve state (pagination, selections):
public class MyBotUser : BaseBotUser {
private Dictionary<string, ScriptPage> pageCache = new();
public override ScriptPage? GetOrCreateCachedPage(string pageId, PageManager pageManager) {
if (pageCache.TryGetValue(pageId, out var cached)) {
return cached;
}
var page = pageManager.GetPage(pageId, this);
if (page != null) {
pageCache[pageId] = page;
}
return page;
}
public void ClearPageCache() {
foreach (var page in pageCache.Values) {
page.Dispose();
}
pageCache.Clear();
}
}- State Preservation - Pagination position, form inputs retained
- Performance - No re-parsing of page definitions
- Memory Efficiency - Component instances reused
- User logs out
- Major state change (language switch)
- User requests reset (
/resetcommand) - After hot reload in development
Pages/
├── auth/
│ ├── login.page
│ └── register.page
├── settings/
│ ├── main.page
│ ├── profile.page
│ └── notifications.page
└── shop/
├── catalog.page
├── cart.page
└── checkout.page
Each page should have a single purpose:
settings.page- Settings menulanguage.page- Language selectiontheme.page- Theme selection
Keep JavaScript light, delegate to ViewModels:
<script>
// Good: ViewModel handles logic
function save() {
VModel.Save();
UI.toast('Saved!');
}
// Avoid: Complex logic in JavaScript
function save() {
var data = collectFormData();
validateData(data);
transformData(data);
// ...
}
</script><script>
function performAction() {
try {
VModel.DoSomething();
UI.toast('Success!');
} catch (e) {
UI.alert('Error: ' + e.message);
}
}
</script>// Show loading state
UI.status('typing');
// Perform action
VModel.ProcessData();
// Show result
UI.toast('Done!');
UI.refresh();