Test / test (push) Successful in 23s
The questions/ tree is the router now: ids derive from file paths
(index.yaml names its directory; explicit id still wins for legacy
content), actions and requires_chain accept relative refs, nested
non-index files infer followup, and _section.yaml applies qualifies/
requires_chain/responsible to everything under its directory. Dynamic
[name].yaml pages serve any /dir/<value> with the segment substituted
into {name} resource-key placeholders; submissions index their chain
node in a portal_chains KV so requires_chain pages can verify a
visitor's ?chain= lineage actually ends at the required question.
Loading uses one recursive git-trees call; question_lint walks
subdirectories the same way. Implements docs/design/filesystem-routes.md.
Also: the YES hero now starts at HTML parse time via an inline module
script (yes.js moved to public/ for a stable /yes.js the wasm binding
raw_module-imports too - snippet paths are per-build-hashed), with
hydration adopting the running instance; and both gesture containers
reserve their box in CSS so mounting doesn't shift content.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
146 lines
5.7 KiB
Rust
146 lines
5.7 KiB
Rust
//! Headless schema-check binary - loads content and `aggregates.yaml`
|
|
//! (from a Gitea repo URL or a local directory) and validates them
|
|
//! exactly the way `content::watch_for_reload`/`main.rs`'s boot path
|
|
//! do, with no NATS, OIDC, web server, or JetStream connection
|
|
//! involved. Built once by portal's own deploy workflow and run
|
|
//! directly by `questions`' own CI (same bare-metal runner/host,
|
|
//! published to a stable path - no artifact download needed), rather
|
|
//! than compiled there - keeps that repo's CI coupling to "run a
|
|
//! static binary," not "build a Rust workspace."
|
|
#![cfg(feature = "ssr")]
|
|
|
|
use portal::{aggregates, content};
|
|
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
let mut args = std::env::args().skip(1);
|
|
let mut repo: Option<String> = None;
|
|
let mut branch = "main".to_string();
|
|
let mut subdir = "questions".to_string();
|
|
let mut path: Option<String> = None;
|
|
|
|
while let Some(arg) = args.next() {
|
|
match arg.as_str() {
|
|
"--repo" => repo = args.next(),
|
|
"--branch" => branch = args.next().unwrap_or(branch),
|
|
"--subdir" => subdir = args.next().unwrap_or(subdir),
|
|
"--path" => path = args.next(),
|
|
other => {
|
|
eprintln!("unknown argument: {other}");
|
|
std::process::exit(2);
|
|
}
|
|
}
|
|
}
|
|
|
|
let (questions, aggregates_map, site) = if let Some(dir) = path {
|
|
(load_from_dir(&dir)?, load_aggregates_from_dir(&dir)?, load_site_from_dir(&dir)?)
|
|
} else if let Some(repo_url) = repo {
|
|
let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?;
|
|
let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch).await?;
|
|
// load_site_from_gitea already validates; a missing file is
|
|
// the default config, same as at portal boot.
|
|
let site = content::load_site_from_gitea(&repo_url, &branch).await?;
|
|
(questions, aggregates_map, site)
|
|
} else {
|
|
eprintln!(
|
|
"usage: question-lint --repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>"
|
|
);
|
|
std::process::exit(2);
|
|
};
|
|
|
|
if let Err(e) = site.validate() {
|
|
eprintln!("FAIL: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
match content::validate_questions(&questions, &aggregates_map) {
|
|
Ok(()) => {
|
|
println!(
|
|
"OK: {} question(s), {} aggregate(s) valid",
|
|
questions.len(),
|
|
aggregates_map.len()
|
|
);
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
eprintln!("FAIL: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The offline counterpart to `content::load_site_from_gitea` -
|
|
/// `site.yaml` lives at the repo root like `aggregates.yaml`, and is
|
|
/// just as optional locally: missing means default branding.
|
|
fn load_site_from_dir(dir: &str) -> anyhow::Result<content::SiteConfig> {
|
|
let site_path = std::path::Path::new(dir)
|
|
.parent()
|
|
.unwrap_or_else(|| std::path::Path::new("."))
|
|
.join("site.yaml");
|
|
if !site_path.exists() {
|
|
return Ok(content::SiteConfig::default());
|
|
}
|
|
let raw = std::fs::read_to_string(&site_path)
|
|
.map_err(|e| anyhow::anyhow!("reading {}: {e}", site_path.display()))?;
|
|
serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing {}: {e}", site_path.display()))
|
|
}
|
|
|
|
/// The offline counterpart to `content::load_aggregates_from_gitea` -
|
|
/// `aggregates.yaml` lives at the repo root, one level up from the
|
|
/// pages directory `--path` names, so `dir`'s parent is where it's
|
|
/// looked for. Missing entirely is not an error here (unlike
|
|
/// `--repo` mode, where `aggregates.yaml` is required) - a local
|
|
/// checkout being linted may not have one, and every declared
|
|
/// transition still gets checked against whatever *is* found; an
|
|
/// empty map just means nothing is checked.
|
|
fn load_aggregates_from_dir(
|
|
dir: &str,
|
|
) -> anyhow::Result<std::collections::HashMap<String, aggregates::AggregateSchema>> {
|
|
let aggregates_path = std::path::Path::new(dir)
|
|
.parent()
|
|
.unwrap_or_else(|| std::path::Path::new("."))
|
|
.join("aggregates.yaml");
|
|
if !aggregates_path.exists() {
|
|
return Ok(std::collections::HashMap::new());
|
|
}
|
|
let raw = std::fs::read_to_string(&aggregates_path)
|
|
.map_err(|e| anyhow::anyhow!("reading {}: {e}", aggregates_path.display()))?;
|
|
aggregates::parse_aggregates_yaml(&raw)
|
|
}
|
|
|
|
/// The offline counterpart to `content::load_questions_from_gitea` -
|
|
/// the same recursive tree walk and `content::build_questions`
|
|
/// pipeline (derived ids, relative refs, sections, followup
|
|
/// inference), just reading a local checkout instead of Gitea's API,
|
|
/// for linting a branch that hasn't been pushed yet.
|
|
fn load_from_dir(
|
|
dir: &str,
|
|
) -> anyhow::Result<std::collections::HashMap<String, content::Question>> {
|
|
let base = std::path::Path::new(dir);
|
|
let mut files = Vec::new();
|
|
collect_yaml(base, base, &mut files)?;
|
|
content::build_questions(&files)
|
|
}
|
|
|
|
fn collect_yaml(
|
|
base: &std::path::Path,
|
|
dir: &std::path::Path,
|
|
out: &mut Vec<(String, String)>,
|
|
) -> anyhow::Result<()> {
|
|
for entry in std::fs::read_dir(dir)? {
|
|
let path = entry?.path();
|
|
if path.is_dir() {
|
|
collect_yaml(base, &path, out)?;
|
|
} else if path.extension().and_then(|e| e.to_str()) == Some("yaml") {
|
|
let rel = path
|
|
.strip_prefix(base)
|
|
.expect("walked paths sit under their base")
|
|
.to_string_lossy()
|
|
.replace('\\', "/");
|
|
let raw = std::fs::read_to_string(&path)
|
|
.map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?;
|
|
out.push((rel, raw));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|