diff --git a/.github/actions/compliance/action.yml b/.github/actions/compliance/action.yml index ebbca491..075ebbd7 100644 --- a/.github/actions/compliance/action.yml +++ b/.github/actions/compliance/action.yml @@ -40,9 +40,23 @@ inputs: default: 'dark' offline: - description: 'When true, uses system fonts instead of loading Google Fonts. For air-gapped environments.' + description: 'When true, uses system fonts instead of loading Google Fonts. For air-gapped environments. Defaults to true for compliance reports — auditors typically open the bundle disconnected.' required: false - default: 'false' + default: 'true' + + single-page: + description: > + When true, emits a single self-contained `index.html` (typically + 1-5 MB) with all sections inline — index / requirements / documents + / STPA / EU AI Act / coverage / matrix / validation / graph. This + is the audit-bundle shape: one file, no per-page navigation, no + external resources, browser-openable offline. + + When false, emits the full multi-page dashboard mirror (hundreds + of pages, ~100s of MB). Useful for browseable hosted docs but + oversized for an audit deliverable. + required: false + default: 'true' # ── Rivet tool ───────────────────────────────────────────────────── rivet-version: @@ -170,6 +184,7 @@ runs: REPORT_HOMEPAGE: ${{ inputs.homepage }} REPORT_VERSIONS: ${{ inputs.other-versions }} REPORT_OFFLINE: ${{ inputs.offline }} + REPORT_SINGLE_PAGE: ${{ inputs.single-page }} run: | ARGS="--format html --output ${REPORT_OUTPUT}" ARGS="$ARGS --theme ${REPORT_THEME}" @@ -187,6 +202,10 @@ runs: ARGS="$ARGS --offline" fi + if [ "$REPORT_SINGLE_PAGE" = "true" ]; then + ARGS="$ARGS --single-page" + fi + eval rivet export $ARGS echo "report-dir=${REPORT_OUTPUT}" >> "$GITHUB_OUTPUT" diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 898f93d7..6e5b99ca 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -6573,9 +6573,9 @@ fn cmd_export_gherkin( fn cmd_export_html( cli: &Cli, output: Option<&std::path::Path>, - _single_page: bool, - _theme: &str, - _offline: bool, + single_page: bool, + theme: &str, + offline: bool, _homepage: Option<&str>, _version_label: Option<&str>, _versions_json: Option<&str>, @@ -6593,6 +6593,51 @@ fn cmd_export_html( let state = serve::reload_state(&project_path, &schemas_dir, 0) .context("loading project for export")?; + // ── Single-page mode (audit-bundle shape) ──────────────────────── + // + // The multi-page export below mirrors the `rivet serve` dashboard + // (~800 pages, ~800 MB) which is useful for browsing but not for an + // audit bundle. `--single-page` short-circuits to the dedicated + // single-page renderer in `rivet_core::export::render_single_page`: + // one self-contained `index.html` with the index / requirements / + // documents / STPA / EU-AI-Act / coverage / matrix / validation / + // graph sections inline, ~3-5 MB total. This is the path the + // `.github/actions/compliance` action calls; the CLI previously + // ignored the flag (the parameters were `_`-prefixed). + if single_page { + let config = rivet_core::export::ExportConfig { + theme: match theme { + "light" => rivet_core::export::ExportTheme::Light, + _ => rivet_core::export::ExportTheme::Dark, + }, + offline, + }; + let project_name = state.context.project_name.clone(); + let version = env!("CARGO_PKG_VERSION").to_string(); + let html = rivet_core::export::render_single_page( + &state.store, + &state.schema, + &state.graph, + &state.cached_diagnostics, + &project_name, + &version, + &config, + &state.doc_store, + ); + let out_dir = output.unwrap_or(std::path::Path::new("dist")); + std::fs::create_dir_all(out_dir) + .with_context(|| format!("creating {}", out_dir.display()))?; + let out_file = out_dir.join("index.html"); + std::fs::write(&out_file, &html) + .with_context(|| format!("writing {}", out_file.display()))?; + println!( + "Exported single-page HTML ({} bytes) to {}", + html.len(), + out_file.display(), + ); + return Ok(true); + } + // Auto-detect baseline snapshot for delta rendering. let snap_dir = project_path.join("snapshots"); let baseline_snapshot = find_latest_snapshot(&snap_dir) diff --git a/rivet-core/src/export.rs b/rivet-core/src/export.rs index e16c5536..7907dcb4 100644 --- a/rivet-core/src/export.rs +++ b/rivet-core/src/export.rs @@ -1871,8 +1871,12 @@ fn render_section_graph(store: &Store, link_graph: &LinkGraph) -> String { .get(n.as_str()) .map(|a| a.title.clone()) .unwrap_or_default(); - let sublabel = if title.len() > 28 { - Some(format!("{}...", &title[..26])) + let sublabel = if title.chars().count() > 28 { + // Truncate by chars, not bytes — `&title[..26]` panics when + // byte 26 falls inside a multi-byte UTF-8 character (e.g. + // the em-dash `—`, 3 bytes). Take 26 chars and append `…`. + let truncated: String = title.chars().take(26).collect(); + Some(format!("{truncated}…")) } else if title.is_empty() { None } else {