feat(examples): add saas tutorial skeleton

Add a compile-tested v1 tutorial skeleton that proves the production-shaped app boundary without provider-heavy scope: auth/session context, CSRF-checked mutation, local persistence adapter, generated form/slot/keyed row/page/push effects, plain CSS, SSE shape, and one explicit metrics island.

req: examples/001

req: auth/001

req: auth/002

req: auth/004

req: form/001

req: failure/004

req: page_swap/002

req: push/003
This commit is contained in:
slhx agent
2026-06-05 09:15:57 +02:00
parent 3dfb3b684f
commit 38cfb263aa
15 changed files with 716 additions and 1 deletions
Generated
+15
View File
@@ -489,6 +489,21 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "hemx-saas-example"
version = "0.1.0"
dependencies = [
"axum",
"futures-util",
"hemplate",
"hemx",
"hemx-axum",
"hemx-build",
"hemx-test",
"scraper",
"tokio",
]
[[package]] [[package]]
name = "hemx-techdemo" name = "hemx-techdemo"
version = "0.1.0" version = "0.1.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["hemx", "hemx-core", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-xtask", "examples/v0", "examples/kanban", "examples/techdemo"] members = ["hemx", "hemx-core", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-xtask", "examples/v0", "examples/kanban", "examples/techdemo", "examples/saas"]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.1.0"
+3
View File
@@ -115,6 +115,9 @@ wire/runtime ABI; and advanced escape hatches that may remain integration-level.
- `examples/v0`: canonical beginner path covering counter, typed todo CRUD, - `examples/v0`: canonical beginner path covering counter, typed todo CRUD,
form wizard, auth action, page swaps, SSE notifications, and keyed list form wizard, auth action, page swaps, SSE notifications, and keyed list
updates. Start here. updates. Start here.
- `examples/saas`: compile-tested v1 tutorial skeleton covering auth/session,
CSRF-safe mutation, local persistence, generated swaps, page/push shape, plain
CSS, and one explicit island without provider-heavy platform scope.
- `examples/kanban`: advanced / north-star milestone boundary sketch. It may - `examples/kanban`: advanced / north-star milestone boundary sketch. It may
expose manual registry or render escape hatches while exploring product limits. expose manual registry or render escape hatches while exploring product limits.
- `examples/techdemo`: advanced integration demo with a leaf island and broader - `examples/techdemo`: advanced integration demo with a leaf island and broader
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "hemx-saas-example"
version.workspace = true
edition.workspace = true
publish = false
[lib]
path = "src/lib.rs"
[[bin]]
name = "hemx-saas-example"
path = "src/main.rs"
[dependencies]
axum = "0.7"
futures-util = "0.3"
hemplate = { path = "../../../hemplate/hemplate" }
hemx = { path = "../../hemx" }
hemx-axum = { path = "../../hemx-axum" }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
[dev-dependencies]
scraper = "0.23"
hemx-test = { path = "../../hemx-test" }
[build-dependencies]
hemx-build = { path = "../../hemx-build" }
+27
View File
@@ -0,0 +1,27 @@
# hemx SaaS tutorial skeleton
This is the compile-tested skeleton for the v1 production-shaped tutorial app. It is intentionally provider-light: auth/session, CSRF, persistence, deploy, metrics, and islands are explicit app boundaries, not hemx core services. req: examples/001 req: auth/001
What it proves today:
- typed form/newtype inputs for project creation
- auth/session context passed through normal Rust state
- CSRF-safe mutation checked before persistence
- local in-memory persistence adapter instead of a vendored SQL/auth provider
- generated form, slot, keyed row, page-swap, and live-status commands
- page shell with plain CSS and one explicit metrics island script
- compile-time surface generation plus interaction tests
What it deliberately does not claim yet:
- real SQLx migrations or a database pool
- production cookie/session middleware
- a deploy target, flags, analytics, billing, or offline sync
- browser automation for the metrics island
Run:
```sh
cargo run -p hemx-saas-example
cargo test -p hemx-saas-example
```
+3
View File
@@ -0,0 +1,3 @@
fn main() {
hemx_build::app().run().unwrap();
}
+463
View File
@@ -0,0 +1,463 @@
#[hemx::surface]
pub mod ui {}
use hemplate::Hemplate;
use hemx::{push, Html, IntoEffect};
use hemx_axum::{
interactions, Form, HandlerErrorContext, HandlerFailure, IntoHandlerFailure, Registry, State,
};
use std::convert::Infallible;
use std::fmt::{Display, Write as _};
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use ui::dashboard;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Session {
user_id: UserId,
email: String,
csrf: CsrfToken,
}
impl Session {
pub fn demo() -> Self {
Self {
user_id: UserId(42),
email: "founder@example.com".to_owned(),
csrf: CsrfToken("demo-csrf".to_owned()),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct UserId(u64);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CsrfToken(String);
impl FromStr for CsrfToken {
type Err = Infallible;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Ok(Self(value.to_owned()))
}
}
impl Display for CsrfToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProjectName(String);
impl ProjectName {
fn as_str(&self) -> &str {
&self.0
}
}
impl FromStr for ProjectName {
type Err = Infallible;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Ok(Self(value.trim().to_owned()))
}
}
#[derive(Clone, Debug)]
#[hemx::form("new_project")]
pub struct NewProject {
csrf: CsrfToken,
name: ProjectName,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ProjectId(u64);
impl Display for ProjectId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProjectRecord {
id: ProjectId,
name: String,
owner: String,
}
#[derive(Clone, Default)]
pub struct LocalProjectStore {
projects: Arc<Mutex<Vec<ProjectRecord>>>,
}
impl LocalProjectStore {
pub fn insert(&self, name: ProjectName, session: &Session) -> Result<ProjectRecord, AppError> {
if name.as_str() == "fail-store" {
return Err(AppError::StoreUnavailable);
}
let mut projects = self.projects.lock().unwrap();
let id = ProjectId(projects.last().map_or(1, |project| project.id.0 + 1));
let record = ProjectRecord {
id,
name: name.as_str().to_owned(),
owner: session.email.clone(),
};
projects.push(record.clone());
Ok(record)
}
pub fn list(&self) -> Vec<ProjectRecord> {
self.projects.lock().unwrap().clone()
}
}
#[derive(Clone)]
pub struct AppContext {
session: Session,
store: LocalProjectStore,
}
impl AppContext {
pub fn demo() -> Self {
Self {
session: Session::demo(),
store: LocalProjectStore::default(),
}
}
pub fn csrf(&self) -> &CsrfToken {
&self.session.csrf
}
pub fn projects(&self) -> Vec<ProjectRecord> {
self.store.list()
}
}
#[derive(Debug)]
pub enum AppError {
MissingSession,
CsrfRejected,
StoreUnavailable,
Validation(&'static str),
}
impl AppError {
fn message(&self) -> &'static str {
match self {
Self::MissingSession => "Sign in to continue",
Self::CsrfRejected => "Refresh the page before creating another project",
Self::StoreUnavailable => "Project storage is temporarily unavailable",
Self::Validation(message) => message,
}
}
}
impl IntoHandlerFailure for AppError {
fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure {
match self {
Self::Validation(message) => HandlerFailure::effects(
(
dashboard::new_project.error("name", message),
dashboard::new_project.focus("name"),
),
context,
),
other => HandlerFailure::effects(dashboard::flash.set(other.message()), context),
}
}
}
pub struct Dashboard {
csrf: CsrfToken,
flash: String,
summary: String,
rows: Vec<ProjectRow>,
project_count: usize,
}
impl Dashboard {
pub fn from_context(ctx: &AppContext) -> Self {
let projects = ctx.projects();
Self {
csrf: ctx.csrf().clone(),
flash: "Signed in with a demo session".to_owned(),
summary: project_summary(projects.len()),
project_count: projects.len(),
rows: projects.into_iter().map(ProjectRow::from).collect(),
}
}
}
#[derive(Hemplate)]
#[hemplate = "partials"]
pub struct ProjectRow {
id: ProjectId,
name: String,
owner: String,
}
impl Hemplate for Dashboard {
fn render_into(&self, buf: &mut String) -> Result<(), hemplate::error::HemplateError> {
buf.push_str("<section class=\"dashboard\" data-hemx-root=\"dashboard\" data-hemx-sse=\"/events\">\n");
buf.push_str(" <header class=\"hero\"><p class=\"eyebrow\">Production-shaped SaaS path</p><h1>Projects</h1><p class=\"lede\">Auth-gated mutations, CSRF checks, local persistence, typed forms, generated swaps, page swaps, live status, plain CSS, and one explicit island.</p></header>\n");
buf.push_str(" <nav class=\"tabs\" data-hemx-slot=\"nav\"><a href=\"/\" data-hemx-nav=\"\">Projects</a><button type=\"button\" data-hemx-handle=\"open_settings\">Settings</button></nav>\n");
buf.push_str(" <section class=\"panel\" data-hemx-slot=\"page_panel\">\n");
buf.push_str(" <form class=\"project-form\" data-hemx-handle=\"create_project\" data-hemx-form=\"new_project\" data-hemx-disable-while-pending>\n");
write!(
buf,
" <input type=\"hidden\" name=\"csrf\" value=\"{}\">\n",
hemplate::HtmlEscape(&self.csrf.to_string())
)?;
buf.push_str(" <label>Project name <input name=\"name\" required=\"required\" maxlength=\"64\" value=\"Launch checklist\"></label><button type=\"submit\">Create project</button><p class=\"field-error\" data-hemx-error-for=\"name\"></p>\n </form>\n");
write!(
buf,
" <p class=\"flash\" data-hemx-slot=\"flash\">{}</p>\n",
hemplate::HtmlEscape(&self.flash)
)?;
write!(
buf,
" <p class=\"summary\" data-hemx-slot=\"summary\">{}</p>\n",
hemplate::HtmlEscape(&self.summary)
)?;
buf.push_str(" <ul class=\"project-list\" data-hemx-slot=\"project_row\">\n");
for row in &self.rows {
buf.push_str(" ");
row.render_into(buf)?;
buf.push('\n');
}
buf.push_str(" </ul>\n </section>\n <section class=\"status-row\"><p data-hemx-slot=\"live_status\">Waiting for status…</p>");
write!(buf, "<article class=\"metrics-island\" data-hemx-island=\"metrics\" data-project-count=\"{}\"><h2>Metrics island</h2><canvas width=\"320\" height=\"140\" aria-label=\"Project metrics chart\"></canvas><p data-island-readout=\"\">Waiting for island script…</p></article>", self.project_count)?;
buf.push_str("</section>\n</section>\n");
Ok(())
}
}
impl From<ProjectRecord> for ProjectRow {
fn from(record: ProjectRecord) -> Self {
Self {
id: record.id,
name: record.name,
owner: record.owner,
}
}
}
impl hemx::KeyedPartial for ProjectRow {
fn hemx_key(&self) -> String {
self.id.to_string()
}
}
#[derive(Hemplate)]
#[hemplate = "partials"]
pub struct SettingsPage {
message: &'static str,
}
#[derive(Hemplate)]
pub struct AppShell {
title: &'static str,
body: Html,
}
pub fn home_page(ctx: &AppContext) -> Html {
hemx::page(&AppShell {
title: "hemx SaaS tutorial",
body: hemx::page(&Dashboard::from_context(ctx)),
})
}
#[hemx::app(dashboard_handlers)]
pub fn registry(ctx: AppContext) -> Registry {
interactions(ui::BUILD_FINGERPRINT)
}
#[hemx::component("dashboard")]
mod dashboard_handlers {
use super::*;
#[hemx::handler]
pub async fn create_project(
State(ctx): State<AppContext>,
Form(form): Form<NewProject>,
) -> Result<impl IntoEffect, AppError> {
if ctx.session.email.is_empty() {
return Err(AppError::MissingSession);
}
if form.csrf != ctx.session.csrf {
return Err(AppError::CsrfRejected);
}
if form.name.as_str().is_empty() {
return Err(AppError::Validation("Project name required"));
}
let project = ctx.store.insert(form.name, &ctx.session)?;
let total = ctx.projects().len();
Ok((
dashboard::project_row.append(ProjectRow::from(project)),
dashboard::summary.set(project_summary(total)),
dashboard::new_project.clear(),
dashboard::flash.set("Project created"),
dashboard::live_status.set(format!("{total} projects persisted locally")),
))
}
#[hemx::handler]
pub async fn open_settings(State(_ctx): State<AppContext>) -> impl IntoEffect {
(
dashboard::page_panel.put(&SettingsPage {
message:
"Auth, CSRF, persistence, metrics, and deploy stay explicit app integrations.",
}),
dashboard::nav.set("Settings"),
push("/settings"),
)
}
}
pub fn live_status(projects: usize) -> impl IntoEffect {
dashboard::live_status.set(format!("heartbeat: {projects} projects"))
}
fn project_summary(total: usize) -> String {
match total {
0 => "No projects yet".to_owned(),
1 => "1 project".to_owned(),
total => format!("{total} projects"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use hemx_axum::{InteractionForm, InteractionRequest};
use hemx_test::{inspect, inspect_batch};
use scraper::{Html as ParsedHtml, Selector};
fn form<I>(handle: hemx::Handle<I>, fields: &[(&str, &str)]) -> InteractionForm {
InteractionForm::for_handle(
handle,
fields
.iter()
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned())),
)
}
fn selector(value: &str) -> Selector {
Selector::parse(value).expect("test selector parses")
}
#[test]
fn home_page_documents_the_production_skeleton_boundaries() {
// req: examples/001 req: auth/001 req: auth/004 req: interop/003
let ctx = AppContext::demo();
let html = home_page(&ctx);
let document = ParsedHtml::parse_document(html.as_str());
assert_eq!(
document
.select(&selector("[data-hemx-root='dashboard']"))
.count(),
1
);
assert_eq!(
document
.select(&selector("form[data-hemx-form='new_project']"))
.count(),
1
);
assert_eq!(document.select(&selector("input[name='csrf']")).count(), 1);
assert_eq!(
document
.select(&selector("[data-hemx-sse='/events']"))
.count(),
1
);
assert_eq!(
document
.select(&selector("[data-hemx-island='metrics']"))
.count(),
1
);
assert!(html.as_str().contains("/app.css"));
assert!(html.as_str().contains("/metrics.js"));
}
#[tokio::test]
async fn create_project_is_auth_csrf_checked_and_persisted_locally() {
// req: examples/001 req: auth/002 req: auth/004 req: form/001 req: failure/004
let ctx = AppContext::demo();
let rejected = inspect_batch(
InteractionRequest::from(form(
dashboard::create_project,
&[("csrf", "stale"), ("name", "Launch checklist")],
))
.dispatch_async(registry(ctx.clone()))
.await
.unwrap()
.batch,
);
assert!(ctx.projects().is_empty());
assert!(rejected.updates_text(dashboard::flash));
assert!(rejected.payload_contains("Refresh the page"));
let validation = inspect_batch(
InteractionRequest::from(form(
dashboard::create_project,
&[("csrf", "demo-csrf"), ("name", " ")],
))
.dispatch_async(registry(ctx.clone()))
.await
.unwrap()
.batch,
);
assert!(ctx.projects().is_empty());
assert!(validation.payload_contains("Project name required"));
let created = inspect_batch(
InteractionRequest::from(form(
dashboard::create_project,
&[("csrf", "demo-csrf"), ("name", "Launch checklist")],
))
.dispatch_async(registry(ctx.clone()))
.await
.unwrap()
.batch,
);
assert_eq!(ctx.projects()[0].name, "Launch checklist");
assert!(created.inserts_html_containing(dashboard::project_row, "1", "Launch checklist"));
assert!(created.updates_text(dashboard::summary));
assert!(created.resets_form(dashboard::new_project));
assert!(created.updates_text(dashboard::live_status));
}
#[tokio::test]
async fn page_swap_and_push_shape_use_generated_targets() {
// req: page_swap/002 req: push/003 req: examples/001
let ctx = AppContext::demo();
let settings = inspect_batch(
InteractionRequest::from(form(dashboard::open_settings, &[]))
.dispatch_async(registry(ctx.clone()))
.await
.unwrap()
.batch,
);
assert!(
settings.updates_html_containing(dashboard::page_panel, "explicit app integrations")
);
assert!(settings.updates_text(dashboard::nav));
assert!(settings.pushes_to("/settings"));
let heartbeat = inspect(live_status(ctx.projects().len()));
assert!(heartbeat.updates_text(dashboard::live_status));
assert!(heartbeat.payload_contains("heartbeat"));
}
}
+82
View File
@@ -0,0 +1,82 @@
use axum::body::Body;
use axum::extract::{Query, State};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use futures_util::stream;
use hemx::IntoEffect;
use hemx_axum::{runtime_js, sse, EffectResponse, InteractionRequest};
use hemx_saas_example::{home_page, live_status, registry, ui, AppContext};
use std::collections::BTreeMap;
use std::convert::Infallible;
#[tokio::main]
async fn main() {
let app = app(AppContext::demo());
let listener = tokio::net::TcpListener::bind("127.0.0.1:3003")
.await
.expect("bind saas tutorial example");
axum::serve(listener, app)
.await
.expect("serve saas tutorial example");
}
fn app(ctx: AppContext) -> Router {
Router::new()
.route("/", get(home).post(interact))
.route("/settings", get(settings))
.route("/events", get(events))
.route("/hemx.js", get(runtime))
.route("/app.css", get(css))
.route("/metrics.js", get(metrics_js))
.with_state(ctx)
}
async fn home(State(ctx): State<AppContext>) -> impl IntoResponse {
axum::response::Html(home_page(&ctx).into_string())
}
async fn settings(State(ctx): State<AppContext>) -> impl IntoResponse {
axum::response::Html(home_page(&ctx).into_string())
}
async fn interact(
State(ctx): State<AppContext>,
request: InteractionRequest,
) -> Result<EffectResponse, impl IntoResponse> {
request.dispatch_async(registry(ctx)).await
}
async fn events(
Query(params): Query<BTreeMap<String, String>>,
State(ctx): State<AppContext>,
) -> impl IntoResponse {
let count = ctx.projects().len();
if params.contains_key("once") {
return sse(stream::iter([Ok::<_, Infallible>(
live_status(count).into_batch(ui::BUILD_FINGERPRINT),
)]));
}
sse(stream::iter([Ok::<_, Infallible>(
live_status(count).into_batch(ui::BUILD_FINGERPRINT),
)]))
}
async fn runtime() -> impl IntoResponse {
runtime_js()
}
async fn css() -> Response {
Response::builder()
.header("content-type", "text/css; charset=utf-8")
.body(Body::from(include_str!("../templates/app.css")))
.expect("css response")
}
async fn metrics_js() -> Response {
Response::builder()
.header("content-type", "text/javascript; charset=utf-8")
.body(Body::from(include_str!("../templates/metrics.js")))
.expect("metrics js response")
}
+16
View File
@@ -0,0 +1,16 @@
:root { color-scheme: light; font-family: Inter, system-ui, sans-serif; }
body { margin: 0; background: #f7f4ee; color: #201b16; }
.dashboard { max-width: 960px; margin: 0 auto; padding: 2rem; }
.hero, .panel, .status-row { background: white; border: 1px solid #e6ded2; border-radius: 18px; padding: 1.25rem; box-shadow: 0 12px 40px rgba(34, 24, 8, 0.08); }
.eyebrow { color: #8a5a00; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
.lede { max-width: 56rem; color: #5d5147; }
.tabs, .project-form, .status-row { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
.tabs { margin: 1rem 0; }
button, input { font: inherit; }
button { border: 0; border-radius: 999px; background: #1f5eff; color: white; padding: .65rem 1rem; }
input { border: 1px solid #cfc4b8; border-radius: 10px; padding: .55rem .7rem; }
.field-error, .flash { color: #a02b12; font-weight: 700; }
.summary { color: #516034; }
.project-list { display: grid; gap: .7rem; padding: 0; list-style: none; }
.project-row { display: flex; justify-content: space-between; border: 1px solid #eee0cb; border-radius: 12px; padding: .75rem; }
.metrics-island { min-width: 18rem; border-left: 4px solid #1f5eff; padding-left: 1rem; }
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{+ self.title +}</title>
<link rel="stylesheet" href="/app.css">
<script src="/hemx.js" defer></script>
<script src="/metrics.js" defer></script>
</head>
<body>
{+= self.body =+}
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
<section class="dashboard" data-hemx-root="dashboard" data-hemx-sse="/events">
<header class="hero">
<p class="eyebrow">Production-shaped SaaS path</p>
<h1>Projects</h1>
<p class="lede">Auth-gated mutations, CSRF checks, local persistence, typed forms, generated swaps, page swaps, live status, plain CSS, and one explicit island.</p>
</header>
<nav class="tabs" data-hemx-slot="nav">
<a href="/" data-hemx-nav="">Projects</a>
<button type="button" data-hemx-handle="open_settings">Settings</button>
</nav>
<section class="panel" data-hemx-slot="page_panel">
<form class="project-form" data-hemx-handle="create_project" data-hemx-form="new_project" data-hemx-disable-while-pending>
<input type="hidden" name="csrf" +value="self.csrf">
<label>Project name
<input name="name" required="required" maxlength="64" value="Launch checklist">
</label>
<button type="submit">Create project</button>
<p class="field-error" data-hemx-error-for="name"></p>
</form>
<p class="flash" data-hemx-slot="flash">{+ self.flash +}</p>
<p class="summary" data-hemx-slot="summary">{+ self.summary +}</p>
<ul class="project-list" data-hemx-slot="project_row">
<template h-for="row in &self.rows" h-key="row.id">
{+ row +}
</template>
</ul>
</section>
<section class="status-row">
<p data-hemx-slot="live_status">Waiting for status…</p>
<article class="metrics-island" data-hemx-island="metrics" +data-project-count="self.project_count">
<h2>Metrics island</h2>
<canvas width="320" height="140" aria-label="Project metrics chart"></canvas>
<p data-island-readout="">Waiting for island script…</p>
</article>
</section>
</section>
+14
View File
@@ -0,0 +1,14 @@
(() => {
function render(island) {
const count = island.getAttribute("data-project-count") || "0";
const readout = island.querySelector("[data-island-readout]");
if (readout) readout.textContent = `${count} persisted project${count === "1" ? "" : "s"}`;
}
function boot() {
for (const island of document.querySelectorAll('[data-hemx-island="metrics"]')) render(island);
}
document.addEventListener("DOMContentLoaded", boot);
document.addEventListener("hemx:after-settle", boot);
})();
@@ -0,0 +1,4 @@
<li class="project-row" +data-key="self.id">
<strong>{+ self.name +}</strong>
<span>{+ self.owner +}</span>
</li>
@@ -0,0 +1,4 @@
<section class="settings-page">
<h2>Settings</h2>
<p>{+ self.message +}</p>
</section>
+2
View File
@@ -301,6 +301,8 @@ fn allowed_example_script(path: &Path, line: &str) -> bool {
line.contains(r#"<script src="/hemx.js" defer></script>"#) line.contains(r#"<script src="/hemx.js" defer></script>"#)
|| (path.ends_with("examples/techdemo/templates/app_shell.heml") || (path.ends_with("examples/techdemo/templates/app_shell.heml")
&& line.contains(r#"<script src="/island.js" defer></script>"#)) && line.contains(r#"<script src="/island.js" defer></script>"#))
|| (path.ends_with("examples/saas/templates/app_shell.heml")
&& line.contains(r#"<script src="/metrics.js" defer></script>"#))
} }
fn contains_inline_event_handler(line: &str) -> bool { fn contains_inline_event_handler(line: &str) -> bool {