一键导入
blog
Extension system, content templates, page prerendering, and CSS management standards for systemprompt.io web development
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Extension system, content templates, page prerendering, and CSS management standards for systemprompt.io web development
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
Search the web for information using the WebSearch tool
Demonstrates governance blocking when plaintext secrets are detected in tool inputs
A simple demonstration skill that searches the web for information
Demonstration skill that attempts to use a plaintext secret, designed to be blocked by governance hooks
List, message, configure, and restart agents via the systemprompt CLI
View traffic, costs, agent stats, and bot detection via the systemprompt CLI
基于 SOC 职业分类
| name | Web Standards |
| description | Extension system, content templates, page prerendering, and CSS management standards for systemprompt.io web development |
Technical guide for extending systemprompt.io with custom templates, components, and data providers.
Extension implementations depend on systemprompt-provider-contracts for provider trait definitions:
systemprompt-provider-contracts
+-- LlmProvider, ToolProvider <- AI/MCP providers
+-- Job, JobContext, JobResult <- Background jobs
+-- ComponentRenderer <- Template components
+-- TemplateDataExtender <- Data extenders
+-- PageDataProvider <- Page data providers
+-- TemplateProvider <- Template definitions
Import via the extension prelude:
use systemprompt::extension::prelude::*;
The prelude re-exports all provider contracts from systemprompt-provider-contracts.
+-----------------------------------------------------------------+
| Extension Project |
| |
| +-----------------+ +-----------------+ +-------------------+ |
| | Templates | | Components | | Data Providers | |
| | services/web/ | | ComponentRender | | PageDataProvider | |
| | templates/ | | trait impl | | TemplateDataExt | |
| +--------+--------+ +--------+--------+ +---------+---------+ |
| | | | |
| +--------------------+----------------------+ |
| | |
| +-----------v------------+ |
| | Extension trait impl | |
| | register_extension!() | |
| +-----------+------------+ |
+-------------------------------|----------------------------------+
| inventory collects at compile time
+-----------|------------+
| TemplateRegistry |
| - providers |
| - loaders |
| - components |
| - page_providers |
| - extenders |
+------------------------+
Extensions define templates in services/web/templates/:
services/web/
templates/
homepage.html <- Homepage template
blog-post.html <- Content type template
blog-list.html <- Parent route template
partials/
header.html <- Shared partials
footer.html
web.yaml <- Template configuration
Templates are discovered via template.yaml files:
name: blog
priority: 500
content_types:
- blog
- article
source:
type: file
path: blog-post.html
| Field | Purpose |
|---|---|
name | Template identifier |
priority | Lower wins (500 = extension, 1000 = core default) |
content_types | Content types this template handles |
source.path | Path relative to templates directory |
Templates receive data via Handlebars context:
<html>
<head>
<title>{{title}}</title>
<meta name="description" content="{{description}}">
<link rel="stylesheet" href="{{CSS_BASE_PATH}}/main.css">
</head>
<body>
<header>
<img src="{{LOGO_PATH}}" alt="{{ORG_NAME}}">
</header>
<main>
{{{CONTENT_HTML}}}
</main>
<aside>
{{{POPULAR_ITEMS_HTML}}}
</aside>
<footer>
{{{FOOTER_NAV}}}
</footer>
<script src="{{JS_BASE_PATH}}/main.js"></script>
</body>
</html>
| Variable | Source | Description |
|---|---|---|
site | web.yaml | Full site configuration |
title | Content item | Page title |
description | Content item | Meta description |
CONTENT_HTML | Rendered markdown | Main content body |
ORG_NAME | content.yaml | Organization name |
ORG_URL | content.yaml | Organization URL |
LOGO_PATH | web.yaml | Logo file path |
FAVICON_PATH | web.yaml | Favicon path |
JS_BASE_PATH | Generated | JavaScript directory |
CSS_BASE_PATH | Generated | CSS directory |
FOOTER_NAV | Generated | Footer navigation HTML |
Components inject dynamic HTML into templates.
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use systemprompt::extension::prelude::*;
pub struct PopularItemsComponent;
#[async_trait]
impl ComponentRenderer for PopularItemsComponent {
fn component_id(&self) -> &str {
"popular-items"
}
fn variable_name(&self) -> &str {
"POPULAR_ITEMS_HTML"
}
fn applies_to(&self) -> Vec<String> {
vec!["blog".to_string(), "homepage".to_string()]
}
async fn render(&self, ctx: &ComponentContext<'_>) -> Result<RenderedComponent> {
let html = match (ctx.all_items, ctx.popular_ids) {
(Some(items), Some(popular_ids)) => {
let popular: Vec<_> = items
.iter()
.filter(|item| {
item.get("id")
.and_then(|id| id.as_str())
.map(|id| popular_ids.contains(&id.to_string()))
.unwrap_or(false)
})
.take(5)
.collect();
render_popular_list(&popular)
}
_ => String::new(),
};
Ok(RenderedComponent::new(self.variable_name(), html))
}
fn priority(&self) -> u32 {
100
}
}
pub struct ComponentContext<'a> {
pub web_config: &'a serde_yaml::Value,
pub item: Option<&'a Value>,
pub all_items: Option<&'a [Value]>,
pub popular_ids: Option<&'a [String]>,
}
| Field | Available For | Description |
|---|---|---|
web_config | All pages | Site configuration |
item | Content pages | Current content item |
all_items | Content pages | All items in source |
popular_ids | Content pages | IDs of popular content |
Use ComponentContext::for_page() for static pages (homepage, about).
Use ComponentContext::for_content() for content pages (blog posts).
Providers inject dynamic data into static pages.
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use systemprompt::extension::prelude::*;
use systemprompt_database::DbPool;
pub struct FeaturedPostsProvider;
#[async_trait]
impl PageDataProvider for FeaturedPostsProvider {
fn provider_id(&self) -> &str {
"featured-posts"
}
fn applies_to_pages(&self) -> Vec<String> {
vec!["homepage".to_string()]
}
async fn provide_page_data(&self, ctx: &PageContext<'_>) -> Result<serde_json::Value> {
let db_pool = ctx.db_pool::<DbPool>()
.ok_or_else(|| anyhow::anyhow!("Database pool not available"))?;
let repo = ContentRepository::new(db_pool)?;
let featured = repo.get_featured(3).await?;
Ok(serde_json::json!({
"featured_posts": featured
.iter()
.map(|p| serde_json::json!({
"title": p.title,
"slug": p.slug,
"description": p.description
}))
.collect::<Vec<_>>()
}))
}
fn priority(&self) -> u32 {
100
}
}
pub struct PageContext<'a> {
pub page_type: &'a str,
pub web_config: &'a serde_yaml::Value,
}
impl PageContext {
pub fn db_pool<T: 'static>(&self) -> Option<&T>;
}
The db_pool() method uses type erasure to access the database pool.
Extenders modify template data before rendering.
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use systemprompt::extension::prelude::*;
pub struct RelatedPostsExtender;
#[async_trait]
impl TemplateDataExtender for RelatedPostsExtender {
fn extender_id(&self) -> &str {
"related-posts"
}
fn applies_to(&self) -> Vec<String> {
vec!["blog".to_string()]
}
async fn extend(&self, ctx: &ExtenderContext<'_>) -> Result<ExtendedData> {
let category = ctx.item
.get("category_id")
.and_then(|v| v.as_str());
let related = match category {
Some(cat) => find_related_posts(ctx.all_items, cat, ctx.item),
None => vec![],
};
Ok(ExtendedData::new()
.with_value("related_posts", serde_json::to_value(&related)?))
}
fn priority(&self) -> u32 {
100
}
}
use std::sync::Arc;
use systemprompt::extension::prelude::*;
pub struct BlogExtension;
impl Extension for BlogExtension {
fn metadata(&self) -> ExtensionMetadata {
ExtensionMetadata {
id: "blog",
name: "Blog Extension",
version: "0.1.0",
}
}
fn component_renderers(&self) -> Vec<Arc<dyn ComponentRenderer>> {
vec![
Arc::new(PopularItemsComponent),
Arc::new(TableOfContentsComponent),
]
}
fn template_data_extenders(&self) -> Vec<Arc<dyn TemplateDataExtender>> {
vec![
Arc::new(RelatedPostsExtender),
]
}
fn page_data_providers(&self) -> Vec<Arc<dyn PageDataProvider>> {
vec![
Arc::new(FeaturedPostsProvider),
]
}
}
register_extension!(BlogExtension);
Extensions register via the inventory crate. At runtime:
let extensions = ExtensionRegistry::discover();
for ext in extensions.extensions() {
for component in ext.component_renderers() {
registry_builder = registry_builder.with_component(component);
}
for extender in ext.template_data_extenders() {
registry_builder = registry_builder.with_extender(extender);
}
for provider in ext.page_data_providers() {
registry_builder = registry_builder.with_page_provider(provider);
}
}
Templates resolve by priority (lower wins):
| Priority | Source |
|---|---|
| 500 | Extension templates |
| 1000 | Core default templates |
This allows extensions to override core templates.
Place assets in services/web/:
services/web/
js/
main.js
analytics.js
css/
main.css
theme.css
assets/
logo.svg
favicon.ico
Reference in templates using base path variables:
<link rel="stylesheet" href="{{CSS_BASE_PATH}}/main.css">
<script src="{{JS_BASE_PATH}}/main.js"></script>
<img src="/assets/logo.svg" alt="Logo">
| Pattern | Description |
|---|---|
| One template per content type | blog-post.html, product.html, event.html |
Shared partials in partials/ | header.html, footer.html, sidebar.html |
Index templates with -list suffix | blog-list.html for /blog route |
| Pattern | Description |
|---|---|
| Single responsibility | One component per feature |
| Graceful degradation | Return empty string if data unavailable |
| Priority ordering | Use priority to control render order |
| Pattern | Description |
|---|---|
| Log and continue | Components log errors but don't fail page |
| Propagate critical errors | Missing required data should fail |
Use context() for error messages | Provide context for debugging |
Each markdown content file has a kind field in its frontmatter that identifies what type of content it is:
---
title: My Blog Post
kind: article
date: 2024-01-15
image: /images/blog/my-post.jpg
---
Common kind values:
article -- Blog posts, news articlestutorial -- Step-by-step guideslegal -- Privacy policies, terms of servicepage -- Generic static pageshomepage -- Homepage contentWhen content is ingested into the database, the kind field becomes the content_type field. This is the value used during prerendering to find the appropriate template.
In content.yaml, each content source can specify which content types it accepts:
content_sources:
blog:
path: services/content/blog
source_id: blog
category_id: blog
enabled: true
allowed_content_types:
- article
- tutorial
Templates declare which content types they can render via templates.yaml:
templates:
blog-post:
content_types:
- article
- tutorial
legal-page:
content_types:
- legal
- page
homepage:
content_types:
- homepage
During prerendering, the system:
content_typetemplate_registry.find_template_for_content_type(content_type)Content (content_type: "article")
|
v
TemplateRegistry.find_template_for_content_type("article")
|
v
Scans templates for one with content_types containing "article"
|
v
Finds "blog-post" template -> Renders content
When multiple templates can handle the same content type, priority determines which is used:
Lower priority numbers win.
Defines content sources and their allowed types:
content_sources:
blog:
path: services/content/blog
source_id: blog
category_id: blog
enabled: true
description: Blog posts
allowed_content_types:
- article
- tutorial
sitemap:
enabled: true
url_pattern: /blog/{slug}
changefreq: weekly
priority: 0.8
Defines available templates and which content types they handle:
templates:
blog-post:
content_types:
- article
- tutorial
homepage:
content_types:
- homepage
Required branding fields:
branding:
copyright: "2024 Your Company. All rights reserved."
twitter_handle: "@yourhandle"
display_sitename: true
favicon: /favicon.ico
logo:
primary:
svg: /images/logo.svg
Define the kind in frontmatter:
---
title: New Case Study
kind: case-study
---
Add to allowed_content_types in content.yaml
Create a template in templates.yaml
Create the template HTML file
Frontmatter kind | Use Case | Typical Template |
|---|---|---|
article | Blog posts, news | blog-post |
tutorial | How-to guides | blog-post or tutorial |
legal | Privacy, terms | legal-page |
page | Static pages | legal-page or page |
homepage | Site homepage | homepage |
landing | Marketing pages | landing-page |
documentation | Docs, references | docs-page |
Page prerendering is fully extension-driven. The core provides:
PagePrerenderer trait for extensions to implementPagePrepareContext with access to configuration and dataExtensions own:
#[async_trait]
pub trait PagePrerenderer: Send + Sync {
fn page_type(&self) -> &str;
fn priority(&self) -> u32 {
100
}
async fn prepare(&self, ctx: &PagePrepareContext<'_>) -> Result<Option<PageRenderSpec>>;
}
pub struct PagePrepareContext<'a> {
pub web_config: &'a WebConfig,
content_config: &'a (dyn Any + Send + Sync),
db_pool: &'a (dyn Any + Send + Sync),
dist_dir: &'a Path,
}
impl<'a> PagePrepareContext<'a> {
pub fn content_config<T: 'static>(&self) -> Option<&T>
pub fn db_pool<T: 'static>(&self) -> Option<&T>
pub fn dist_dir(&self) -> &Path
}
pub struct PageRenderSpec {
pub template_name: String,
pub base_data: serde_json::Value,
pub output_path: PathBuf,
}
use std::path::PathBuf;
use anyhow::Result;
use async_trait::async_trait;
use systemprompt_models::ContentConfigRaw;
use systemprompt_provider_contracts::{
PagePrepareContext, PagePrerenderer, PageRenderSpec,
};
const PAGE_TYPE: &str = "docs-index";
const TEMPLATE_NAME: &str = "docs-index";
const OUTPUT_FILE: &str = "docs/index.html";
#[derive(Debug, Clone, Copy, Default)]
pub struct DocsIndexPrerenderer;
#[async_trait]
impl PagePrerenderer for DocsIndexPrerenderer {
fn page_type(&self) -> &str {
PAGE_TYPE
}
fn priority(&self) -> u32 {
100
}
async fn prepare(&self, ctx: &PagePrepareContext<'_>) -> Result<Option<PageRenderSpec>> {
let base_data = serde_json::json!({
"site": ctx.web_config,
"page_title": "Documentation"
});
Ok(Some(PageRenderSpec::new(
TEMPLATE_NAME,
base_data,
PathBuf::from(OUTPUT_FILE),
)))
}
}
use std::sync::Arc;
use systemprompt_extension::prelude::*;
impl Extension for MyExtension {
fn page_prerenderers(&self) -> Vec<Arc<dyn PagePrerenderer>> {
vec![Arc::new(DocsIndexPrerenderer)]
}
}
ExtensionRegistry::discover() finds all extensionspage_prerenderers()TemplateRegistryprepare() builds the render specPageDataProviders and ComponentRenderers for the page typeLower priority values indicate higher importance and execute first.
| Priority | Use Case |
|---|---|
| 0-49 | Critical -- overrides defaults |
| 50-99 | Core application pages |
| 100 | Default (fallback, easily overridden) |
| 101+ | Low priority/optional |
Ok(None) to skip rendering (template not found, feature disabled)Err(...) for actual failuressystemprompt core content publish --step pages
systemprompt core content publish
| File | Purpose |
|---|---|
crates/shared/provider-contracts/src/page_prerenderer.rs | Trait definitions |
crates/shared/extension/src/lib.rs | Extension trait with page_prerenderers() |
crates/domain/templates/src/registry.rs | Registry for prerenderers |
crates/app/generator/src/prerender/engine.rs | Execution engine |
crates/domain/content/src/homepage_prerenderer.rs | Default homepage implementation |
All CSS files go in storage/files/css/ and must be registered in extensions/web/src/extension.rs.
storage/files/css/ <- CSS SOURCE (put files here)
extensions/web/src/extension.rs <- REGISTER here in required_assets()
web/dist/css/ <- OUTPUT (generated, never edit)
NEVER put CSS in extensions/*/assets/css/.
storage/files/css/extensions/web/src/extension.rs via the required_assets() method (which delegates to web_assets() in assets.rs)just build && systemprompt infra jobs run copy_extension_assetsThe WebExtension implements Extension::required_assets() which calls web_assets(paths) from crate::assets. New CSS files must be added to the asset definitions returned by this function.
just build # Build all extensions
systemprompt infra jobs run copy_extension_assets # Copy assets to dist/
systemprompt web validate # Validate output
| File | Purpose |
|---|---|
storage/files/css/ | CSS source directory |
extensions/web/src/extension.rs | Extension registration with required_assets() |
extensions/web/src/assets.rs | Asset definitions (web_assets function) |
web/dist/css/ | Generated output (never edit directly) |