Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
307fd7e753 | ||
|
|
30c2b6bea9 | ||
|
|
b875045bbc | ||
|
|
a1fc333079 | ||
|
|
0d6cdb8a00 | ||
|
|
b2342f074b | ||
|
|
7429f1b9e1 | ||
|
|
a0305282ef | ||
|
|
c67f6f1a59 |
Generated
+1
-1
@@ -2948,7 +2948,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.2.0"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.2.0"
|
version = "0.2.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -19,7 +19,7 @@ axum = { version = "0.8", features = ["multipart"], optional = true }
|
|||||||
aws-sdk-s3 = { version = "1", optional = true }
|
aws-sdk-s3 = { version = "1", optional = true }
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"], optional = true }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"], optional = true }
|
||||||
tower = { version = "0.5", optional = true }
|
tower = { version = "0.5", optional = true }
|
||||||
tower-http = { version = "0.6", features = ["fs"], optional = true }
|
tower-http = { version = "0.6", features = ["fs", "set-header"], optional = true }
|
||||||
tower-sessions = { version = "0.14", optional = true }
|
tower-sessions = { version = "0.14", optional = true }
|
||||||
async-nats = { version = "0.38", optional = true }
|
async-nats = { version = "0.38", optional = true }
|
||||||
arc-swap = { version = "1", optional = true }
|
arc-swap = { version = "1", optional = true }
|
||||||
|
|||||||
@@ -29,8 +29,13 @@ stream.
|
|||||||
|
|
||||||
- **Content-driven pages** (`src/content.rs`, `src/app.rs`): YAML
|
- **Content-driven pages** (`src/content.rs`, `src/app.rs`): YAML
|
||||||
loaded from Gitea at boot and hot-swapped on a NATS reload signal;
|
loaded from Gitea at boot and hot-swapped on a NATS reload signal;
|
||||||
a bad push keeps the last-good content serving. A page's `id` is
|
a bad push keeps the last-good content serving. The `questions/`
|
||||||
its URL; `qualifies` gates it to a Kanidm group.
|
tree IS the router — file paths become URLs, `_section.yaml`
|
||||||
|
applies criteria to a whole directory, `[name].yaml` pages serve
|
||||||
|
any `/dir/<value>` with the segment fed into resource keys, and
|
||||||
|
`requires_chain` gates a page on verifiable answer provenance next
|
||||||
|
to `qualifies`' Kanidm-group identity gate (see
|
||||||
|
`docs/design/filesystem-routes.md`).
|
||||||
- **State machines as content** (`src/aggregates/`): `aggregates.yaml`
|
- **State machines as content** (`src/aggregates/`): `aggregates.yaml`
|
||||||
declares each bucket's states and legal transitions; the engine
|
declares each bucket's states and legal transitions; the engine
|
||||||
replays a record's event history and refuses undeclared moves, with
|
replays a record's event history and refuses undeclared moves, with
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
# Filesystem routes, sections, and where chains fit
|
# Filesystem routes, sections, and where chains fit
|
||||||
|
|
||||||
Status: proposal (researched 2026-08-24, nothing implemented).
|
Status: implemented in v0.2.0 (2026-08-24) — all three phases, with
|
||||||
|
one deviation: dynamic-page params substitute into resource keys via
|
||||||
|
server-side `resolve_question` at lookup time (get_question,
|
||||||
|
find_feature, submit_answer all resolve concrete paths), so no param
|
||||||
|
threading exists client-side. The user-facing routing contract is
|
||||||
|
documented in uhhm/questions' README ("Routing: the tree is the
|
||||||
|
router"); this file stays as the design rationale.
|
||||||
|
|
||||||
## What exists today, precisely
|
## What exists today, precisely
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ pub struct AggregateSchema {
|
|||||||
pub event_for_state: HashMap<String, String>,
|
pub event_for_state: HashMap<String, String>,
|
||||||
pub state_for_event: HashMap<String, String>,
|
pub state_for_event: HashMap<String, String>,
|
||||||
pub transitions: HashMap<String, Vec<String>>,
|
pub transitions: HashMap<String, Vec<String>>,
|
||||||
|
/// Names what consumes this bucket's answers when no page in the
|
||||||
|
/// content repo reads it (e.g. "n8n newsletter compose").
|
||||||
|
/// Free-text - it exists so `validate_questions`' attended-bucket
|
||||||
|
/// rule has an explicit, auditable opt-out instead of a silent
|
||||||
|
/// one, and so the next reader knows where the answers go.
|
||||||
|
pub attended_by: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AggregateSchema {
|
impl AggregateSchema {
|
||||||
@@ -57,6 +63,8 @@ struct RawAggregateSchema {
|
|||||||
states: HashMap<String, RawState>,
|
states: HashMap<String, RawState>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
transitions: HashMap<String, Vec<String>>,
|
transitions: HashMap<String, Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
attended_by: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
@@ -123,6 +131,7 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
|||||||
event_for_state,
|
event_for_state,
|
||||||
state_for_event,
|
state_for_event,
|
||||||
transitions: raw_schema.transitions,
|
transitions: raw_schema.transitions,
|
||||||
|
attended_by: raw_schema.attended_by,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-19
@@ -54,20 +54,61 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
|
|||||||
pub fn App() -> impl IntoView {
|
pub fn App() -> impl IntoView {
|
||||||
provide_meta_context();
|
provide_meta_context();
|
||||||
|
|
||||||
|
// ONE site resource and ONE <Title> for the whole app, both living
|
||||||
|
// outside the router. Two dueling Title components (a static
|
||||||
|
// SITE_NAME fallback here + a per-page override) turned every SPA
|
||||||
|
// navigation into a remount race that the compile-time fallback
|
||||||
|
// kept winning - redoal.com's tab flipped to "uhhm" on the first
|
||||||
|
// client-side nav. App never remounts, so this Title never
|
||||||
|
// unmounts; the Suspense makes SSR await the resolved title so the
|
||||||
|
// served <head> is right too. Pages read the same resource via
|
||||||
|
// context instead of fetching their own copy.
|
||||||
|
let site = Resource::new(|| (), |_| get_site());
|
||||||
|
provide_context(site);
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Stylesheet id="leptos" href="/pkg/portal.css"/>
|
<Stylesheet id="leptos" href="/pkg/portal.css"/>
|
||||||
<Title text=SITE_NAME/>
|
<Suspense fallback=|| ()>
|
||||||
|
{move || {
|
||||||
|
site.get()
|
||||||
|
.map(|res| {
|
||||||
|
let title = res
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.title)
|
||||||
|
.unwrap_or_else(|| SITE_NAME.to_string());
|
||||||
|
view! { <Title text=title/> }
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
<Router>
|
<Router>
|
||||||
<Routes fallback=|| view! { <NotFound/> }>
|
<PortalShell/>
|
||||||
<Route path=path!("") view=QuestionPage/>
|
|
||||||
<Route path=path!("/*any") view=QuestionPage/>
|
|
||||||
</Routes>
|
|
||||||
</Router>
|
</Router>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every server-fn resource the pages read, created exactly once for
|
||||||
|
/// the app's lifetime and handed down via context. Wrapper structs
|
||||||
|
/// because a bare `Resource<T>` context is claimed by whoever provides
|
||||||
|
/// that T last.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct QuestionRes(Resource<Result<Option<Page>, ServerFnError>>);
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct UserRes(Resource<Result<Option<User>, ServerFnError>>);
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct NavRes(Resource<Result<Vec<(String, String)>, ServerFnError>>);
|
||||||
|
|
||||||
|
/// Owns the app's data resources, above the routes and reactive on the
|
||||||
|
/// location instead of recreated per page. Route components creating
|
||||||
|
/// their own resources broke on the first client-side navigation: the
|
||||||
|
/// remounted component's fresh Resource consumed a stale SSR hydration
|
||||||
|
/// buffer instead of fetching - concretely, the nav list
|
||||||
|
/// `[[id, name], ..]` deserialized as a `Page` (serde fills a struct
|
||||||
|
/// from a sequence in field order), rendering the landing question
|
||||||
|
/// gated behind its own nav entry. Stable resources + context makes
|
||||||
|
/// that class of misalignment impossible: navigation only changes a
|
||||||
|
/// key, and a key change always refetches.
|
||||||
#[component]
|
#[component]
|
||||||
fn QuestionPage() -> impl IntoView {
|
fn PortalShell() -> impl IntoView {
|
||||||
let location = use_location();
|
let location = use_location();
|
||||||
let query = use_query_map();
|
let query = use_query_map();
|
||||||
let path = Memo::new(move |_| {
|
let path = Memo::new(move |_| {
|
||||||
@@ -78,15 +119,37 @@ fn QuestionPage() -> impl IntoView {
|
|||||||
p
|
p
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let parent_hash = Memo::new(move |_| query.with(|q| q.get("chain")));
|
let chain = Memo::new(move |_| query.with(|q| q.get("chain")));
|
||||||
let query_email = Memo::new(move |_| query.with(|q| q.get("email")));
|
|
||||||
|
|
||||||
let question = Resource::new(
|
let question = Resource::new(
|
||||||
move || (path.get(), parent_hash.get()),
|
move || (path.get(), chain.get()),
|
||||||
|(path, chain)| get_question(path, chain),
|
|(path, chain)| get_question(path, chain),
|
||||||
);
|
);
|
||||||
let user = Resource::new(|| (), |_| current_user());
|
let user = Resource::new(|| (), |_| current_user());
|
||||||
let site = Resource::new(|| (), |_| get_site());
|
let nav = Resource::new(move || chain.get().is_some(), list_qualifying_questions);
|
||||||
|
provide_context(QuestionRes(question));
|
||||||
|
provide_context(UserRes(user));
|
||||||
|
provide_context(NavRes(nav));
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Routes fallback=|| view! { <NotFound/> }>
|
||||||
|
<Route path=path!("") view=QuestionPage/>
|
||||||
|
<Route path=path!("/*any") view=QuestionPage/>
|
||||||
|
</Routes>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn QuestionPage() -> impl IntoView {
|
||||||
|
let query = use_query_map();
|
||||||
|
let parent_hash = Memo::new(move |_| query.with(|q| q.get("chain")));
|
||||||
|
let query_email = Memo::new(move |_| query.with(|q| q.get("email")));
|
||||||
|
|
||||||
|
// All shared, app-lifetime resources (see PortalShell / App) -
|
||||||
|
// never created per navigation.
|
||||||
|
let QuestionRes(question) = expect_context();
|
||||||
|
let UserRes(user) = expect_context();
|
||||||
|
let site = expect_context::<Resource<Result<SiteConfig, ServerFnError>>>();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Suspense fallback=|| {
|
<Suspense fallback=|| {
|
||||||
@@ -109,13 +172,6 @@ fn QuestionPage() -> impl IntoView {
|
|||||||
Ok(Some(page)) => {
|
Ok(Some(page)) => {
|
||||||
let current = user_res.and_then(|r| r.ok()).flatten();
|
let current = user_res.and_then(|r| r.ok()).flatten();
|
||||||
view! {
|
view! {
|
||||||
// A second <Title> outranks App's
|
|
||||||
// SITE_NAME fallback only when content
|
|
||||||
// actually declares one.
|
|
||||||
{site_cfg
|
|
||||||
.title
|
|
||||||
.clone()
|
|
||||||
.map(|t| view! { <Title text=t/> })}
|
|
||||||
<QuestionView
|
<QuestionView
|
||||||
question=page.question
|
question=page.question
|
||||||
chain_gate=page.chain_gate
|
chain_gate=page.chain_gate
|
||||||
@@ -144,6 +200,7 @@ fn QuestionView(
|
|||||||
site: SiteConfig,
|
site: SiteConfig,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let question_id = question.id.clone();
|
let question_id = question.id.clone();
|
||||||
|
let has_chain = parent_hash.is_some();
|
||||||
|
|
||||||
// The provenance counterpart to the qualifies gate below: a
|
// The provenance counterpart to the qualifies gate below: a
|
||||||
// requires_chain page whose visitor holds no verifiable lineage to
|
// requires_chain page whose visitor holds no verifiable lineage to
|
||||||
@@ -170,6 +227,8 @@ fn QuestionView(
|
|||||||
</a>
|
</a>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
// A gate is never a dead end: the same nav as every page.
|
||||||
|
<QuestionNav current_id=question_id has_chain=has_chain/>
|
||||||
}
|
}
|
||||||
.into_any();
|
.into_any();
|
||||||
}
|
}
|
||||||
@@ -205,6 +264,7 @@ fn QuestionView(
|
|||||||
}}
|
}}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<QuestionNav current_id=question_id has_chain=has_chain/>
|
||||||
}
|
}
|
||||||
.into_any();
|
.into_any();
|
||||||
}
|
}
|
||||||
@@ -214,7 +274,6 @@ fn QuestionView(
|
|||||||
// on pages where some alternative actually declares a deck.
|
// on pages where some alternative actually declares a deck.
|
||||||
let needs_swiper = question.alternatives.iter().any(|a| a.images.len() > 1);
|
let needs_swiper = question.alternatives.iter().any(|a| a.images.len() > 1);
|
||||||
|
|
||||||
let has_chain = parent_hash.is_some();
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
{needs_swiper.then(|| view! { <leptos_meta::Script src="/swiper-element-bundle.min.js"/> })}
|
{needs_swiper.then(|| view! { <leptos_meta::Script src="/swiper-element-bundle.min.js"/> })}
|
||||||
@@ -277,7 +336,8 @@ fn QuestionView(
|
|||||||
/// claiming front-page space (an owner also sees the gated desks here).
|
/// claiming front-page space (an owner also sees the gated desks here).
|
||||||
#[component]
|
#[component]
|
||||||
fn QuestionNav(current_id: String, has_chain: bool) -> impl IntoView {
|
fn QuestionNav(current_id: String, has_chain: bool) -> impl IntoView {
|
||||||
let nav = Resource::new(move || has_chain, list_qualifying_questions);
|
let _ = has_chain; // keyed into the shared resource by PortalShell
|
||||||
|
let NavRes(nav) = expect_context();
|
||||||
view! {
|
view! {
|
||||||
<Suspense fallback=|| ()>
|
<Suspense fallback=|| ()>
|
||||||
{move || {
|
{move || {
|
||||||
|
|||||||
@@ -948,6 +948,54 @@ pub fn validate_questions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attended buckets: no publicly collected answer may land somewhere
|
||||||
|
// nothing reads. Every `record_as` bucket must either be read back
|
||||||
|
// by some Kv resource in this same content repo (a desk or listing)
|
||||||
|
// or carry an explicit `attended_by:` annotation in aggregates.yaml
|
||||||
|
// naming the automation that consumes it. This is a contract, not a
|
||||||
|
// convention, because convention already failed once: a question
|
||||||
|
// was dropped and its bucket - answers included - silently fell out
|
||||||
|
// of every reader's view.
|
||||||
|
let mut read_buckets = std::collections::HashSet::new();
|
||||||
|
let note_resource = |spec: &ResourceSpec, set: &mut std::collections::HashSet<String>| {
|
||||||
|
if let ResourceSource::Kv { bucket } = &spec.source {
|
||||||
|
set.insert(bucket.clone());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for question in questions.values() {
|
||||||
|
for alternative in &question.alternatives {
|
||||||
|
for feature in &alternative.features {
|
||||||
|
if let Some(spec) = &feature.resource {
|
||||||
|
note_resource(spec, &mut read_buckets);
|
||||||
|
}
|
||||||
|
for requirement in &feature.requirements {
|
||||||
|
if let Some(spec) = &requirement.resource {
|
||||||
|
note_resource(spec, &mut read_buckets);
|
||||||
|
}
|
||||||
|
if let Some(bind) = &requirement.bind {
|
||||||
|
note_resource(&bind.resource, &mut read_buckets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for question in questions.values() {
|
||||||
|
for alternative in &question.alternatives {
|
||||||
|
if let Some(bucket) = &alternative.record_as {
|
||||||
|
let attended = read_buckets.contains(bucket)
|
||||||
|
|| aggregates
|
||||||
|
.get(bucket)
|
||||||
|
.is_some_and(|schema| schema.attended_by.is_some());
|
||||||
|
if !attended {
|
||||||
|
anyhow::bail!(
|
||||||
|
"question {:?} alternative {:?}: record_as {bucket:?} is unattended - no page reads that bucket back, and aggregates.yaml declares no attended_by for it. Add a desk/listing resource over it, or annotate the automation that consumes it.",
|
||||||
|
question.id, alternative.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1501,6 +1549,40 @@ alternatives:
|
|||||||
assert!(validate_questions(&questions, &Default::default()).is_err());
|
assert!(validate_questions(&questions, &Default::default()).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recorded_buckets_must_be_attended() {
|
||||||
|
let write_only = vec![(
|
||||||
|
"index.yaml".to_string(),
|
||||||
|
"name: Home\nalternatives:\n - name: send\n record_as: black_hole\n".to_string(),
|
||||||
|
)];
|
||||||
|
let questions = build_questions(&write_only).unwrap();
|
||||||
|
let err = validate_questions(&questions, &Default::default()).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("unattended"), "got: {err}");
|
||||||
|
|
||||||
|
// A desk (any Kv resource over the bucket, anywhere in the
|
||||||
|
// repo) attends it.
|
||||||
|
let with_desk = vec![
|
||||||
|
(
|
||||||
|
"index.yaml".to_string(),
|
||||||
|
"name: Home\nalternatives:\n - name: send\n record_as: inbox\n".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"review/index.yaml".to_string(),
|
||||||
|
"name: Desk\nalternatives:\n - name: Inbox\n features:\n - name: \"\"\n resource:\n requires_group: owners\n source:\n kind: kv\n bucket: inbox\n".to_string(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let questions = build_questions(&with_desk).unwrap();
|
||||||
|
assert!(validate_questions(&questions, &Default::default()).is_ok());
|
||||||
|
|
||||||
|
// ..or an explicit attended_by annotation in aggregates.yaml.
|
||||||
|
let aggregates = crate::aggregates::parse_aggregates_yaml(
|
||||||
|
"aggregates:\n - bucket: black_hole\n initial: open\n attended_by: n8n nightly digest\n states:\n open: { event: submitted }\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let questions = build_questions(&write_only).unwrap();
|
||||||
|
assert!(validate_questions(&questions, &aggregates).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn actions_may_not_target_dynamic_pages() {
|
fn actions_may_not_target_dynamic_pages() {
|
||||||
let files = vec![
|
let files = vec![
|
||||||
|
|||||||
+27
-2
@@ -143,7 +143,22 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/upload", post(upload::upload))
|
.route("/upload", post(upload::upload))
|
||||||
.route("/gitea-repo", get(content::gitea_repo_handler))
|
.route("/gitea-repo", get(content::gitea_repo_handler))
|
||||||
.route("/automation/kv/{bucket}", get(content::automation_kv_handler))
|
.route("/automation/kv/{bucket}", get(content::automation_kv_handler))
|
||||||
.nest_service("/pkg", ServeDir::new(pkg_dir))
|
// no-cache = "revalidate before reuse", not "don't cache":
|
||||||
|
// pkg files keep the same names across releases (portal.js,
|
||||||
|
// portal.wasm), and without this browsers heuristically cache
|
||||||
|
// them - a stale wasm from the previous release then talks to
|
||||||
|
// a server whose server-fn wire format has moved on and every
|
||||||
|
// page renders as its error branch. A 304 per load is the
|
||||||
|
// price of never shipping that skew again.
|
||||||
|
.nest_service(
|
||||||
|
"/pkg",
|
||||||
|
tower::ServiceBuilder::new()
|
||||||
|
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
|
||||||
|
axum::http::header::CACHE_CONTROL,
|
||||||
|
axum::http::HeaderValue::from_static("no-cache"),
|
||||||
|
))
|
||||||
|
.service(ServeDir::new(pkg_dir)),
|
||||||
|
)
|
||||||
.nest_service("/fonts", ServeDir::new(fonts_dir))
|
.nest_service("/fonts", ServeDir::new(fonts_dir))
|
||||||
.route_service("/favicon-light.svg", ServeFile::new(favicon_light_path))
|
.route_service("/favicon-light.svg", ServeFile::new(favicon_light_path))
|
||||||
.route_service("/favicon-dark.svg", ServeFile::new(favicon_dark_path))
|
.route_service("/favicon-dark.svg", ServeFile::new(favicon_dark_path))
|
||||||
@@ -152,7 +167,17 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
"/swiper-element-bundle.min.js",
|
"/swiper-element-bundle.min.js",
|
||||||
ServeFile::new(swiper_path),
|
ServeFile::new(swiper_path),
|
||||||
)
|
)
|
||||||
.route_service("/yes.js", ServeFile::new(yes_path))
|
// Same skew concern as /pkg: the wasm's raw_module import and
|
||||||
|
// the hero's inline script both load this by fixed name.
|
||||||
|
.route_service(
|
||||||
|
"/yes.js",
|
||||||
|
tower::ServiceBuilder::new()
|
||||||
|
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
|
||||||
|
axum::http::header::CACHE_CONTROL,
|
||||||
|
axum::http::HeaderValue::from_static("no-cache"),
|
||||||
|
))
|
||||||
|
.service(ServeFile::new(yes_path)),
|
||||||
|
)
|
||||||
.leptos_routes_with_context(
|
.leptos_routes_with_context(
|
||||||
&state,
|
&state,
|
||||||
routes,
|
routes,
|
||||||
|
|||||||
Reference in New Issue
Block a user