diff --git a/crates/renderflow-core/data/profiles/magazine-v1.yaml b/crates/renderflow-core/data/profiles/magazine-v1.yaml new file mode 100644 index 0000000..f28d526 --- /dev/null +++ b/crates/renderflow-core/data/profiles/magazine-v1.yaml @@ -0,0 +1,43 @@ +schema: renderflow.profile/v1 +description: Versioned multi-channel magazine release bundle +intermediates: cache_only +targets: + - id: publication.print.press + role: print/press + format: pdf + - id: publication.print.proof + role: print/proof + format: pdf + - id: publication.digital.screen + role: digital/screen + format: pdf + requirement: optional + - id: publication.digital.web + role: digital/web/index + format: html + - id: publication.editorial.markdown + role: editorial/normalized + format: markdown + - id: publication.editorial.docx + role: editorial/document + format: docx + requirement: optional + - id: publication.digital.epub + role: digital/issue + format: epub + requirement: optional + - id: publication.digital.kepub + role: digital/issue + format: kepub + requirement: optional + - id: publication.assets.cover-preview + role: assets/previews/cover + format: png + requirement: optional +policy: + validation: + required: true + failure_mode: branch_local + allow_unavailable: true + network: deny + ai: deny diff --git a/crates/renderflow-core/src/cli.rs b/crates/renderflow-core/src/cli.rs index 32ab900..5587371 100644 --- a/crates/renderflow-core/src/cli.rs +++ b/crates/renderflow-core/src/cli.rs @@ -60,7 +60,9 @@ pub enum Commands { renderflow build --optimization speed Build using speed optimization mode\n \ renderflow build --optimization pareto Build with Pareto-optimal path selection\n \ renderflow build --target pdf Build only the PDF output via graph resolution\n \ - renderflow build --profile everything Build the maximal available artifact forest")] + renderflow build --profile everything Build the maximal available artifact forest + \ + renderflow build --profile magazine Build a versioned magazine release bundle")] Build { /// Path to the renderflow configuration file #[arg(long, default_value = "renderflow.yaml", value_name = "FILE")] @@ -87,7 +89,7 @@ pub enum Commands { #[arg(long, value_name = "FORMAT", conflicts_with_all = ["all", "profile"])] target: Option, - /// Build a named, versioned derivative profile. `everything` is bundled. + /// Build a named, versioned derivative profile. `everything` and `magazine` are bundled. #[arg(long, value_name = "PROFILE", conflicts_with_all = ["target", "all"])] profile: Option, @@ -504,7 +506,7 @@ pub enum GraphCommands { #[arg(long, value_name = "FORMAT", conflicts_with = "profile")] target: Option, - /// Resolve a named, versioned derivative profile. `everything` is bundled. + /// Resolve a named, versioned derivative profile. `everything` and `magazine` are bundled. #[arg(long, value_name = "PROFILE", conflicts_with = "target")] profile: Option, diff --git a/crates/renderflow-core/src/lib.rs b/crates/renderflow-core/src/lib.rs index 49c0196..d8dac79 100644 --- a/crates/renderflow-core/src/lib.rs +++ b/crates/renderflow-core/src/lib.rs @@ -30,6 +30,7 @@ pub mod optimization; mod pipeline; pub mod planning; pub mod process; +pub mod publication; mod sdk; pub mod spec; pub mod strategies; diff --git a/crates/renderflow-core/src/planning.rs b/crates/renderflow-core/src/planning.rs index 81f3120..8367279 100644 --- a/crates/renderflow-core/src/planning.rs +++ b/crates/renderflow-core/src/planning.rs @@ -31,6 +31,7 @@ use crate::graph::{ use crate::hygiene::{HygieneEngine, HygieneEvidence}; use crate::intake::{IntakeEngine, IntakeRequest, ResolvedArtifactProfile}; use crate::optimization::OptimizationMode; +use crate::publication::write_release_metadata; use crate::spec::{ load_spec, AiPolicy, CollisionPolicy, DerivativeProfile, HygienePolicy, IntermediatePolicy, RejectedLossClass, SelectorSet, SourceKind, SourceSpec, SourceSpecVersion, SpecV2, @@ -296,6 +297,18 @@ pub fn resolve(request: PlanningRequest) -> Result { source_path.display() ); } + if let Some(publication) = &spec.publication { + for artwork in &publication.artwork { + let path = resolve_path_relative_to_config(&request.config_path, &artwork.path); + if !path.is_file() { + anyhow::bail!( + "publication artwork role '{}' does not resolve to a file: '{}'", + artwork.role, + path.display() + ); + } + } + } // Universal intake establishes immutable byte identity and multi-signal // evidence before the graph is selected. The temporary CAS is only a // planning staging area; execution imports the same bytes into its durable @@ -341,6 +354,7 @@ pub fn resolve(request: PlanningRequest) -> Result { if targets.is_empty() { anyhow::bail!("target selection resolved to no executable artifact formats"); } + validate_publication_target_roles(&spec, &targets)?; let provider_inventory = tool_registry.assess_ids_current(policy_graph.provider_ids()); let available_graph = @@ -877,7 +891,7 @@ pub fn execute(mut resolved: ResolvedExecution, dry_run: bool) -> Result Result Result>(); let producing_step = report.steps.iter().find(|step| { step.output_artifacts .iter() @@ -1216,32 +1243,70 @@ fn artifact_evidence( let fidelity = producing_step .map(|step| step.fidelity) .unwrap_or(FidelityDeclaration::Unknown); - let locator = output_locators - .get(artifact.id().as_str()) - .cloned() - .unwrap_or_else(|| artifact_store_locator(artifact)); - let mut artifact_evidence = ArtifactEvidence::from_artifact( - artifact, role, lifecycle, locator, producer, validation, fidelity, - ); - artifact_evidence.validation_evidence = outcome - .map(|outcome| outcome.validators.clone()) - .unwrap_or_default(); - artifact_evidence.hygiene = hygiene_evidence.get(artifact.id().as_str()).cloned(); - if let Some(step) = producing_step { - artifact_evidence.warnings = diagnostics - .iter() - .filter(|diagnostic| { - diagnostic.severity == DiagnosticSeverity::Warning - && diagnostic.step_id.as_deref() == Some(step.step_id.as_str()) - }) - .map(|diagnostic| diagnostic.message.clone()) - .collect(); + let target_instances = if targets.is_empty() { + vec![None] + } else { + targets.into_iter().map(Some).collect() + }; + for target in target_instances { + let lifecycle = if target.is_some() { + ArtifactRole::Terminal + } else { + ArtifactRole::Intermediate + }; + let role = target + .and_then(|target| target.role.clone().or_else(|| target.id.clone())) + .unwrap_or_else(|| artifact.format().to_string()); + let locator = target + .and_then(|target| output_locators.get(&target_evidence_key(target)).cloned()) + .unwrap_or_else(|| artifact_store_locator(artifact)); + let mut item = ArtifactEvidence::from_artifact( + artifact, + role, + lifecycle, + locator, + producer.clone(), + validation, + fidelity, + ); + item.validation_evidence = outcome + .map(|outcome| outcome.validators.clone()) + .unwrap_or_default(); + item.hygiene = hygiene_evidence.get(artifact.id().as_str()).cloned(); + if let Some(target) = target { + item.metadata.insert( + "renderflow.target.id".to_string(), + serde_json::json!(target.id), + ); + item.metadata.insert( + "renderflow.target.options".to_string(), + serde_json::json!(target.options), + ); + } + if let Some(step) = producing_step { + item.warnings = diagnostics + .iter() + .filter(|diagnostic| { + diagnostic.severity == DiagnosticSeverity::Warning + && diagnostic.step_id.as_deref() == Some(step.step_id.as_str()) + }) + .map(|diagnostic| diagnostic.message.clone()) + .collect(); + } + evidence.push(item); } - evidence.push(artifact_evidence); } evidence } +fn target_evidence_key(target: &ResolvedTarget) -> String { + target + .id + .clone() + .or_else(|| target.role.clone()) + .unwrap_or_else(|| target.format.to_string()) +} + fn artifact_store_locator(artifact: &Artifact) -> String { format!( "artifact-store:{}", @@ -1336,10 +1401,21 @@ fn apply_request_overrides(spec: &mut SpecV2, request: &PlanningRequest) -> Resu ..TargetSelection::default() }; } else if let Some(profile) = &request.profile { - if profile == "everything" && !spec.profiles.contains_key(profile) { - let bundled: DerivativeProfile = - serde_yaml_ng::from_str(include_str!("../data/profiles/everything-v1.yaml")) - .context("bundled everything profile is invalid")?; + if matches!(profile.as_str(), "everything" | "magazine") + && !spec.profiles.contains_key(profile) + { + let (source, label) = match profile.as_str() { + "magazine" => ( + include_str!("../data/profiles/magazine-v1.yaml"), + "magazine", + ), + _ => ( + include_str!("../data/profiles/everything-v1.yaml"), + "everything", + ), + }; + let bundled: DerivativeProfile = serde_yaml_ng::from_str(source) + .with_context(|| format!("bundled {label} profile is invalid"))?; spec.profiles.insert(profile.clone(), bundled); } if !spec.profiles.contains_key(profile) { @@ -1862,80 +1938,77 @@ fn build_artifact_forest( unavailable: &[ResolvedTarget], budget_pruned: &[ResolvedTarget], ) -> ArtifactForest { - let selected_formats = selected - .iter() - .map(|target| target.format) - .collect::>(); - let unavailable_formats = unavailable - .iter() - .map(|target| target.format) - .collect::>(); - let budget_pruned_formats = budget_pruned - .iter() - .map(|target| target.format) - .collect::>(); let mut exclusions = spec.targets.exclude.clone(); for profile_name in &spec.targets.profiles { if let Some(profile) = spec.profiles.get(profile_name) { merge_selector_set(&mut exclusions, &profile.exclude); } } - let mut formats = graph.reachable_from(source); - formats.sort_by_key(ToString::to_string); - let branches = formats - .into_iter() - .filter_map(|format| { - let target = ResolvedTarget::generated(format); - let (state, reason_code, reason) = if selected_formats.contains(&format) { - ( - ForestBranchState::Selected, - "branch.selected", - "reachable, policy-allowed, and provider-available", - ) - } else if unavailable_formats.contains(&format) { - ( - ForestBranchState::Unavailable, - "branch.provider_unavailable", - "a required provider is unavailable on this host", - ) - } else if budget_pruned_formats.contains(&format) { - ( - ForestBranchState::BudgetPruned, - "branch.max_artifacts", - "pruned by execution.budgets.max_artifacts", - ) - } else if selector_matches(&target, &exclusions, graph) { - ( - ForestBranchState::Excluded, - "branch.selector_excluded", - "matched a profile, spec, or CLI exclusion selector", - ) - } else if spec.targets.all_reachable { - return None; - } else { - return None; - }; - let resolved_target = selected - .iter() - .chain(unavailable.iter()) - .chain(budget_pruned.iter()) - .find(|candidate| candidate.format == format) - .unwrap_or(&target); - Some(ForestBranch { - format: format.to_string(), - role: resolved_target.role.clone(), - requirement: match resolved_target.requirement { - TargetRequirement::Required => "required", - TargetRequirement::Optional => "optional", - } - .to_string(), - options: resolved_target.options.clone(), - state, - reason_code: reason_code.to_string(), - reason: reason.to_string(), - }) + let branch = |target: &ResolvedTarget, + state: ForestBranchState, + reason_code: &str, + reason: &str| ForestBranch { + format: target.format.to_string(), + role: target.role.clone(), + requirement: match target.requirement { + TargetRequirement::Required => "required", + TargetRequirement::Optional => "optional", + } + .to_string(), + options: target.options.clone(), + state, + reason_code: reason_code.to_string(), + reason: reason.to_string(), + }; + let mut branches = selected + .iter() + .map(|target| { + branch( + target, + ForestBranchState::Selected, + "branch.selected", + "reachable, policy-allowed, and provider-available", + ) }) - .collect(); + .chain(unavailable.iter().map(|target| { + branch( + target, + ForestBranchState::Unavailable, + "branch.provider_unavailable", + "a required provider is unavailable on this host", + ) + })) + .chain(budget_pruned.iter().map(|target| { + branch( + target, + ForestBranchState::BudgetPruned, + "branch.max_artifacts", + "pruned by execution.budgets.max_artifacts", + ) + })) + .collect::>(); + let represented = selected + .iter() + .chain(unavailable.iter()) + .chain(budget_pruned.iter()) + .map(|target| target.format) + .collect::>(); + for format in graph.reachable_from(source) { + let target = ResolvedTarget::generated(format); + if !represented.contains(&format) && selector_matches(&target, &exclusions, graph) { + branches.push(branch( + &target, + ForestBranchState::Excluded, + "branch.selector_excluded", + "matched a profile, spec, or CLI exclusion selector", + )); + } + } + branches.sort_by(|left, right| { + left.format + .cmp(&right.format) + .then_with(|| left.role.cmp(&right.role)) + }); ArtifactForest { profiles: spec .targets @@ -2033,8 +2106,16 @@ fn insert_target(selected: &mut Vec, candidate: ResolvedTarget) if is_generated(&candidate) { return Ok(()); } + let shares_render = existing.preset == candidate.preset + && existing.template == candidate.template + && existing.variant == candidate.variant + && existing.options == candidate.options; + if shares_render { + selected.push(candidate); + return Ok(()); + } anyhow::bail!( - "multiple distinct target configurations resolve to format '{}'; format-only DAG nodes cannot represent parallel same-format variants until Transform v2 (#357)", + "multiple target roles resolve to format '{}' with different render configurations; use a shared preset/template/options or Transform v2 target nodes", candidate.format ); } @@ -2162,6 +2243,28 @@ fn unsupported_targets_error( ) } +fn validate_publication_target_roles(spec: &SpecV2, targets: &[ResolvedTarget]) -> Result<()> { + let Some(publication) = &spec.publication else { + return Ok(()); + }; + for (role, constraints) in &publication.output_roles { + let Some(target) = targets + .iter() + .find(|target| target.role.as_deref() == Some(role.as_str())) + else { + continue; + }; + if target.format.to_string() != constraints.format { + anyhow::bail!( + "publication output role '{role}' expects format '{}' but the selected target resolves to '{}'", + constraints.format, + target.format + ); + } + } + Ok(()) +} + fn register_builtin_strategy_executors( executor: &mut DagExecutor, dag: &MultiTargetDag, @@ -2348,6 +2451,15 @@ fn render_output_paths(resolved: &ResolvedExecution) -> Result> { *count += 1; paths.push(destination); } + if resolved.spec.publication.is_some() { + paths.extend([ + root.join("metadata/publication.json"), + root.join("metadata/manifest.json"), + root.join("metadata/provenance.json"), + root.join("metadata/preflight.json"), + root.join("metadata/checksums.sha256"), + ]); + } Ok(paths) } @@ -2412,6 +2524,7 @@ mod tests { }], profiles: BTreeMap::new(), hygiene: BTreeMap::new(), + publication: None, targets: TargetSelection::default(), execution: ExecutionPolicy::default(), output: OutputLayout::default(), diff --git a/crates/renderflow-core/src/publication.rs b/crates/renderflow-core/src/publication.rs new file mode 100644 index 0000000..1ef9b6b --- /dev/null +++ b/crates/renderflow-core/src/publication.rs @@ -0,0 +1,492 @@ +//! Provider-neutral publication contracts and deterministic release metadata. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::evidence::{ArtifactEvidence, DigestEvidence, ValidationState}; +use crate::spec::HygienePolicy; +use crate::toolchain::ToolchainSnapshot; + +pub const PUBLICATION_CONTRACT_V1: &str = "renderflow.publication/v1"; +pub const PUBLICATION_BUNDLE_V1: &str = "renderflow.publication-bundle/v1"; + +fn default_publication_schema() -> String { + PUBLICATION_CONTRACT_V1.to_string() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PublicationStatus { + #[default] + Draft, + Reviewed, + Approved, + Released, +} + +impl PublicationStatus { + pub fn is_release_intent(self) -> bool { + matches!(self, Self::Approved | Self::Released) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PublicationContributor { + pub name: String, + pub role: String, + #[serde(default)] + pub identifier: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PublicationAsset { + pub role: String, + pub path: String, + #[serde(default)] + pub alt_text: Option, + #[serde(default)] + pub approval_reference: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PageGeometry { + pub width: f64, + pub height: f64, + #[serde(default = "default_geometry_unit")] + pub unit: String, + #[serde(default)] + pub margin: Option, + #[serde(default)] + pub bleed: Option, + #[serde(default)] + pub safe_area: Option, +} + +fn default_geometry_unit() -> String { + "mm".to_string() +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PublicationRights { + #[serde(default)] + pub license: Option, + #[serde(default)] + pub rights_holder: Option, + #[serde(default)] + pub approval_reference: Option, + #[serde(default)] + pub reviewed: bool, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AccessibilityMetadata { + #[serde(default)] + pub summary: Option, + #[serde(default)] + pub access_modes: Vec, + #[serde(default)] + pub hazards: Vec, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PublicationRoleConstraints { + pub format: String, + #[serde(default)] + pub stage: String, + #[serde(default)] + pub geometry: Option, + #[serde(default)] + pub color_policy: Option, + #[serde(default)] + pub minimum_image_dpi: Option, + #[serde(default)] + pub require_embedded_fonts: bool, + #[serde(default)] + pub validators: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PublicationContract { + #[serde(default = "default_publication_schema")] + pub schema: String, + pub publication: String, + #[serde(default)] + pub series: Option, + pub issue_id: String, + #[serde(default)] + pub issue_number: Option, + pub title: String, + #[serde(default)] + pub subtitle: Option, + #[serde(default)] + pub contributors: Vec, + pub publication_date: String, + #[serde(default)] + pub status: PublicationStatus, + pub language: String, + #[serde(default)] + pub artwork: Vec, + pub geometry: PageGeometry, + #[serde(default)] + pub color_policy: Option, + #[serde(default)] + pub font_policy: Option, + #[serde(default)] + pub asset_policy: Option, + #[serde(default)] + pub rights: PublicationRights, + #[serde(default)] + pub accessibility: AccessibilityMetadata, + #[serde(default)] + pub canonical_url: Option, + #[serde(default)] + pub identifiers: BTreeMap, + /// Explicit, independently inspectable constraints keyed by target role. + #[serde(default)] + pub output_roles: BTreeMap, + #[serde(default)] + pub extensions: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublicationContractDiagnostic { + pub path: String, + pub code: String, + pub message: String, +} + +impl PublicationContract { + pub fn diagnostics( + &self, + hygiene: Option<&HygienePolicy>, + ) -> Vec { + let mut diagnostics = Vec::new(); + if self.schema != PUBLICATION_CONTRACT_V1 { + diagnostics.push(diagnostic( + "$.publication.schema", + "publication.schema.unsupported", + "supported publication schema is renderflow.publication/v1", + )); + } + for (path, value) in [ + ("$.publication.publication", self.publication.as_str()), + ("$.publication.issue_id", self.issue_id.as_str()), + ("$.publication.title", self.title.as_str()), + ( + "$.publication.publication_date", + self.publication_date.as_str(), + ), + ("$.publication.language", self.language.as_str()), + ] { + if value.trim().is_empty() { + diagnostics.push(diagnostic( + path, + "publication.required.empty", + "required publication value must not be empty", + )); + } + } + if self.geometry.width <= 0.0 || self.geometry.height <= 0.0 { + diagnostics.push(diagnostic( + "$.publication.geometry", + "publication.geometry.invalid", + "page width and height must be greater than zero", + )); + } + for (role, constraints) in &self.output_roles { + if role.trim().is_empty() || constraints.format.trim().is_empty() { + diagnostics.push(diagnostic( + "$.publication.output_roles", + "publication.output_role.invalid", + "publication output roles and their formats must not be empty", + )); + } + if constraints.minimum_image_dpi == Some(0) { + diagnostics.push(diagnostic( + &format!("$.publication.output_roles.{role}.minimum_image_dpi"), + "publication.output_role.dpi_invalid", + "minimum image DPI must be greater than zero", + )); + } + } + for (index, contributor) in self.contributors.iter().enumerate() { + if contributor.name.trim().is_empty() || contributor.role.trim().is_empty() { + diagnostics.push(diagnostic( + &format!("$.publication.contributors[{index}]"), + "publication.contributor.invalid", + "contributor names and roles must not be empty", + )); + } + } + if self.status.is_release_intent() { + for (path, value) in [ + ( + "$.publication.rights.license", + self.rights.license.as_deref(), + ), + ( + "$.publication.rights.rights_holder", + self.rights.rights_holder.as_deref(), + ), + ( + "$.publication.rights.approval_reference", + self.rights.approval_reference.as_deref(), + ), + ] { + if value.is_none_or(|value| value.trim().is_empty()) { + diagnostics.push(diagnostic( + path, + "publication.release.rights_missing", + "approved and released publications require complete rights metadata", + )); + } + } + if !self.rights.reviewed { + diagnostics.push(diagnostic( + "$.publication.rights.reviewed", + "publication.release.rights_unreviewed", + "approved and released publications require an explicit rights review", + )); + } + let visual_access = self + .accessibility + .access_modes + .iter() + .any(|mode| mode.eq_ignore_ascii_case("visual")); + for (index, artwork) in self.artwork.iter().enumerate() { + if artwork + .approval_reference + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + diagnostics.push(diagnostic( + &format!("$.publication.artwork[{index}].approval_reference"), + "publication.release.artwork_unapproved", + "approved and released artwork requires an approval reference", + )); + } + if visual_access + && artwork + .alt_text + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + diagnostics.push(diagnostic( + &format!("$.publication.artwork[{index}].alt_text"), + "publication.release.alt_text_missing", + "visual artwork requires alt text for this accessibility contract", + )); + } + } + match hygiene { + Some(policy) if policy.rights.required && policy.rights.reviewed => { + for (field, publication_value, hygiene_value) in [ + ( + "license", + self.rights.license.as_deref(), + policy.rights.license.as_deref(), + ), + ( + "rights_holder", + self.rights.rights_holder.as_deref(), + policy.rights.rights_holder.as_deref(), + ), + ( + "approval_reference", + self.rights.approval_reference.as_deref(), + policy.rights.approval_reference.as_deref(), + ), + ] { + if publication_value != hygiene_value { + diagnostics.push(diagnostic( + &format!("$.publication.rights.{field}"), + "publication.release.rights_mismatch", + "publication and hygiene rights metadata must match for release", + )); + } + } + } + _ => diagnostics.push(diagnostic( + "$.execution.hygiene_policy", + "publication.release.hygiene_required", + "approved and released publications require a hygiene policy with reviewed rights gates", + )), + } + } + diagnostics + } +} + +fn diagnostic(path: &str, code: &str, message: &str) -> PublicationContractDiagnostic { + PublicationContractDiagnostic { + path: path.to_string(), + code: code.to_string(), + message: message.to_string(), + } +} + +#[derive(Debug, Clone, Serialize)] +struct PublicationBundleManifest<'a> { + schema: &'static str, + publication: &'a PublicationContract, + artifacts: &'a [ArtifactEvidence], +} + +#[derive(Debug, Clone, Serialize)] +struct PublicationProvenance<'a> { + schema: &'static str, + source_spec_digest: &'a DigestEvidence, + execution_plan_digest: &'a DigestEvidence, + toolchain: Option<&'a ToolchainSnapshot>, +} + +#[derive(Debug, Clone, Serialize)] +struct PublicationPreflight<'a> { + schema: &'static str, + roles: Vec>, +} + +#[derive(Debug, Clone, Serialize)] +struct PublicationRolePreflight<'a> { + role: &'a str, + constraints: &'a PublicationRoleConstraints, + artifact_id: Option<&'a str>, + validation: ValidationState, + diagnostics: Vec<&'static str>, +} + +pub fn write_release_metadata( + output_root: &Path, + contract: &PublicationContract, + artifacts: &[ArtifactEvidence], + output_paths: &[String], + source_spec_digest: &DigestEvidence, + execution_plan_digest: &DigestEvidence, + toolchain: Option<&ToolchainSnapshot>, +) -> Result> { + let metadata_root = output_root.join("metadata"); + fs::create_dir_all(&metadata_root).with_context(|| { + format!( + "failed to create publication metadata directory '{}'", + metadata_root.display() + ) + })?; + let publication_path = metadata_root.join("publication.json"); + let manifest_path = metadata_root.join("manifest.json"); + let provenance_path = metadata_root.join("provenance.json"); + let preflight_path = metadata_root.join("preflight.json"); + write_json(&publication_path, contract)?; + write_json( + &manifest_path, + &PublicationBundleManifest { + schema: PUBLICATION_BUNDLE_V1, + publication: contract, + artifacts, + }, + )?; + write_json( + &provenance_path, + &PublicationProvenance { + schema: PUBLICATION_BUNDLE_V1, + source_spec_digest, + execution_plan_digest, + toolchain, + }, + )?; + let roles = contract + .output_roles + .iter() + .map(|(role, constraints)| { + let artifact = artifacts.iter().find(|artifact| artifact.role == *role); + let mut validation = artifact + .map(|artifact| artifact.validation) + .unwrap_or(ValidationState::Unavailable); + let mut diagnostics = Vec::new(); + if artifact.is_none() { + diagnostics.push("role was not materialized on this host"); + } + if constraints.geometry.is_some() { + diagnostics.push("deep PDF geometry inspection requires a configured validator"); + } + if constraints.minimum_image_dpi.is_some() { + diagnostics.push("embedded image resolution requires a configured validator"); + } + if constraints.require_embedded_fonts { + diagnostics.push("font embedding requires a configured validator"); + } + if !diagnostics.is_empty() && validation == ValidationState::Valid { + validation = ValidationState::ValidWithWarnings; + } + PublicationRolePreflight { + role, + constraints, + artifact_id: artifact.map(|artifact| artifact.artifact_id.as_str()), + validation, + diagnostics, + } + }) + .collect(); + write_json( + &preflight_path, + &PublicationPreflight { + schema: PUBLICATION_BUNDLE_V1, + roles, + }, + )?; + + let mut checksum_inputs = output_paths.iter().map(PathBuf::from).collect::>(); + checksum_inputs.extend([ + publication_path.clone(), + manifest_path.clone(), + provenance_path.clone(), + preflight_path.clone(), + ]); + checksum_inputs.sort(); + checksum_inputs.dedup(); + let checksum_path = metadata_root.join("checksums.sha256"); + let mut checksum_text = String::new(); + for path in checksum_inputs.iter().filter(|path| path.is_file()) { + let bytes = fs::read(path) + .with_context(|| format!("failed to read publication artifact '{}'", path.display()))?; + let digest = format!("{:x}", Sha256::digest(bytes)); + let locator = path.strip_prefix(output_root).unwrap_or(path); + checksum_text.push_str(&format!("{digest} {}\n", locator.display())); + } + fs::write(&checksum_path, checksum_text).with_context(|| { + format!( + "failed to write publication checksums '{}'", + checksum_path.display() + ) + })?; + + Ok([ + publication_path, + manifest_path, + provenance_path, + preflight_path, + checksum_path, + ] + .into_iter() + .map(|path| path.display().to_string()) + .collect()) +} + +fn write_json(path: &Path, value: &impl Serialize) -> Result<()> { + let mut bytes = serde_json::to_vec_pretty(value)?; + bytes.push(b'\n'); + fs::write(path, bytes) + .with_context(|| format!("failed to write publication metadata '{}'", path.display())) +} diff --git a/crates/renderflow-core/src/spec.rs b/crates/renderflow-core/src/spec.rs index f77563a..c52370a 100644 --- a/crates/renderflow-core/src/spec.rs +++ b/crates/renderflow-core/src/spec.rs @@ -8,6 +8,7 @@ use serde_json::{json, Value}; use crate::config::Config; use crate::optimization::OptimizationMode; +use crate::publication::PublicationContract; pub const SPEC_V2_ID: &str = "renderflow/v2"; pub const SPEC_V2_SCHEMA_PATH: &str = "schemas/renderflow-v2.schema.json"; @@ -561,6 +562,8 @@ pub struct SpecV2 { pub profiles: BTreeMap, #[serde(default)] pub hygiene: BTreeMap, + #[serde(default)] + pub publication: Option, pub targets: TargetSelection, #[serde(default)] pub execution: ExecutionPolicy, @@ -774,6 +777,20 @@ impl SpecV2 { } } + if let Some(publication) = &self.publication { + let hygiene = self + .execution + .hygiene_policy + .as_deref() + .and_then(|policy| self.hygiene.get(policy)); + diagnostics.extend( + publication + .diagnostics(hygiene) + .into_iter() + .map(|item| SpecDiagnostic::new(item.path, item.code, item.message)), + ); + } + for (profile_name, profile) in &self.profiles { let base = format!("$.profiles.{profile_name}"); if !is_stable_id(profile_name) { @@ -1284,6 +1301,7 @@ pub(crate) fn migrate_v1_config(config: &Config) -> SpecV2 { }], profiles: BTreeMap::new(), hygiene: BTreeMap::new(), + publication: None, targets: TargetSelection { exact, profiles: Vec::new(), @@ -1327,6 +1345,12 @@ pub fn json_schema() -> Value { "additionalProperties": {"$ref": "#/$defs/hygienePolicy"}, "default": {} }, + "publication": { + "anyOf": [ + {"$ref": "#/$defs/publicationContract"}, + {"type": "null"} + ] + }, "targets": {"$ref": "#/$defs/targetSelection"}, "execution": {"$ref": "#/$defs/executionPolicy"}, "output": {"$ref": "#/$defs/outputLayout"}, @@ -1339,6 +1363,95 @@ pub fn json_schema() -> Value { }, "$defs": { "stableId": {"type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9._-]+$"}, + "publicationContributor": { + "type": "object", "additionalProperties": false, + "required": ["name", "role"], + "properties": { + "name": {"type": "string", "minLength": 1}, + "role": {"type": "string", "minLength": 1}, + "identifier": {"type": ["string", "null"]} + } + }, + "publicationAsset": { + "type": "object", "additionalProperties": false, + "required": ["role", "path"], + "properties": { + "role": {"type": "string", "minLength": 1}, + "path": {"type": "string", "minLength": 1}, + "alt_text": {"type": ["string", "null"]}, + "approval_reference": {"type": ["string", "null"]} + } + }, + "pageGeometry": { + "type": "object", "additionalProperties": false, + "required": ["width", "height"], + "properties": { + "width": {"type": "number", "exclusiveMinimum": 0}, + "height": {"type": "number", "exclusiveMinimum": 0}, + "unit": {"type": "string", "default": "mm"}, + "margin": {"type": ["number", "null"], "minimum": 0}, + "bleed": {"type": ["number", "null"], "minimum": 0}, + "safe_area": {"type": ["number", "null"], "minimum": 0} + } + }, + "publicationRights": { + "type": "object", "additionalProperties": false, + "properties": { + "license": {"type": ["string", "null"]}, + "rights_holder": {"type": ["string", "null"]}, + "approval_reference": {"type": ["string", "null"]}, + "reviewed": {"type": "boolean", "default": false} + } + }, + "accessibilityMetadata": { + "type": "object", "additionalProperties": false, + "properties": { + "summary": {"type": ["string", "null"]}, + "access_modes": {"type": "array", "items": {"type": "string"}, "default": []}, + "hazards": {"type": "array", "items": {"type": "string"}, "default": []} + } + }, + "publicationRoleConstraints": { + "type": "object", "additionalProperties": false, + "required": ["format"], + "properties": { + "format": {"type": "string", "minLength": 1}, + "stage": {"type": "string", "default": ""}, + "geometry": {"anyOf": [{"$ref": "#/$defs/pageGeometry"}, {"type": "null"}]}, + "color_policy": {"type": ["string", "null"]}, + "minimum_image_dpi": {"type": ["integer", "null"], "minimum": 1}, + "require_embedded_fonts": {"type": "boolean", "default": false}, + "validators": {"type": "array", "items": {"type": "string"}, "default": []} + } + }, + "publicationContract": { + "type": "object", "additionalProperties": false, + "required": ["publication", "issue_id", "title", "publication_date", "language", "geometry"], + "properties": { + "schema": {"const": "renderflow.publication/v1", "default": "renderflow.publication/v1"}, + "publication": {"type": "string", "minLength": 1}, + "series": {"type": ["string", "null"]}, + "issue_id": {"type": "string", "minLength": 1}, + "issue_number": {"type": ["string", "null"]}, + "title": {"type": "string", "minLength": 1}, + "subtitle": {"type": ["string", "null"]}, + "contributors": {"type": "array", "items": {"$ref": "#/$defs/publicationContributor"}, "default": []}, + "publication_date": {"type": "string", "minLength": 1}, + "status": {"enum": ["draft", "reviewed", "approved", "released"], "default": "draft"}, + "language": {"type": "string", "minLength": 1}, + "artwork": {"type": "array", "items": {"$ref": "#/$defs/publicationAsset"}, "default": []}, + "geometry": {"$ref": "#/$defs/pageGeometry"}, + "color_policy": {"type": ["string", "null"]}, + "font_policy": {"type": ["string", "null"]}, + "asset_policy": {"type": ["string", "null"]}, + "rights": {"$ref": "#/$defs/publicationRights"}, + "accessibility": {"$ref": "#/$defs/accessibilityMetadata"}, + "canonical_url": {"type": ["string", "null"]}, + "identifiers": {"type": "object", "additionalProperties": {"type": "string"}, "default": {}}, + "output_roles": {"type": "object", "additionalProperties": {"$ref": "#/$defs/publicationRoleConstraints"}, "default": {}}, + "extensions": {"type": "object", "default": {}} + } + }, "source": { "type": "object", "additionalProperties": false, diff --git a/crates/renderflow-core/src/super_resolution.rs b/crates/renderflow-core/src/super_resolution.rs index 066ba9e..3d4b5f2 100644 --- a/crates/renderflow-core/src/super_resolution.rs +++ b/crates/renderflow-core/src/super_resolution.rs @@ -1122,6 +1122,7 @@ mod tests { }], profiles: BTreeMap::new(), hygiene: BTreeMap::new(), + publication: None, targets: TargetSelection::default(), execution: ExecutionPolicy { ai: AiPolicy::LocalOnly, diff --git a/docs/cli-reference/build.md b/docs/cli-reference/build.md index 70eb976..7810648 100644 --- a/docs/cli-reference/build.md +++ b/docs/cli-reference/build.md @@ -5,7 +5,7 @@ Build documents from a Renderflow config. ## Syntax ```bash -renderflow build [--config FILE] [--dry-run] [--resume] [--optimization MODE] [--target FORMAT | --all] +renderflow build [--config FILE] [--dry-run] [--resume] [--optimization MODE] [--target FORMAT | --profile PROFILE | --all] ``` ## Flags @@ -17,6 +17,7 @@ renderflow build [--config FILE] [--dry-run] [--resume] [--optimization MODE] [- | `--resume` | reuse only compatible, validated node checkpoints | | `--optimization MODE` | override config optimization mode | | `--target FORMAT` | graph-build one reachable target; requires `transforms` | +| `--profile PROFILE` | build a named versioned profile; `everything` and `magazine` are bundled | | `--all` | graph-build all reachable targets; requires `transforms` | ## Standard build behavior @@ -53,5 +54,6 @@ renderflow build --dry-run renderflow build --resume renderflow build --optimization quality renderflow build --target pdf +renderflow build --config examples/magazine/renderflow.yaml --profile magazine --dry-run renderflow build --all --optimization speed ``` diff --git a/docs/user-guide/magazine-publications.md b/docs/user-guide/magazine-publications.md new file mode 100644 index 0000000..4203c34 --- /dev/null +++ b/docs/user-guide/magazine-publications.md @@ -0,0 +1,61 @@ +# Magazine publication profile + +The bundled `magazine` profile turns one reviewed editorial source into a capability-driven release tree. It is a `renderflow.profile/v1` document consumed by the normal planner; there is no magazine-specific executor. + +## Authoring contract + +Add a `publication` block using `renderflow.publication/v1`. It records the publication and issue identity, contributors, date and status, language, artwork roles, trim geometry, color and font policies, rights approval, accessibility metadata, identifiers, and role-specific output constraints. + +Use `examples/magazine/renderflow.yaml` as a complete redistribution-safe starting point. Keep editorial content and approved assets in the content repository; Renderflow owns derivative planning, execution evidence, hygiene, validation, and packaging metadata. + +Statuses are `draft`, `reviewed`, `approved`, and `released`. An `approved` or `released` contract is rejected unless it contains a license, rights holder, approval reference, an explicit rights review, and a selected hygiene policy whose rights gate is required and reviewed. Renderflow records that decision; it does not make a legal conclusion. + +## Preview the artifact forest + +From the fixture directory: + +```bash +renderflow graph plan --config renderflow.yaml --profile magazine +renderflow build --config renderflow.yaml --profile magazine --dry-run +``` + +The forest reports optional targets such as KEPUB or cover previews as unavailable when their adapters are absent. It never reports a pruned branch as produced. + +## Proof and release roles + +The profile declares separate `print/press`, `print/proof`, and `digital/screen` roles. Their constraints live under `publication.output_roles`, where geometry, color policy, minimum image DPI, font embedding, stage, and validator expectations can differ. + +When these roles share the same preset, template, variant, and provider-neutral options, the generic DAG renders PDF once and materializes the content-addressed artifact under each role. This is useful when proofing the exact release bytes. Publications requiring differently rendered PDF variants should declare different Transform v2 target nodes rather than claiming shared output. + +## Build and inspect + +```bash +renderflow build --config renderflow.yaml --profile magazine +renderflow inspect --config renderflow.yaml --all +``` + +Inspect `release/renderflow-run.json` and the files under `release/metadata/` with ordinary JSON and checksum tooling after the build. + +Available adapters determine the exact tree. A completed publication build also writes: + +- `metadata/publication.json` — the normalized publication contract; +- `metadata/manifest.json` — publication identity plus artifact evidence; +- `metadata/provenance.json` — source-spec, execution-plan, and toolchain evidence; +- `metadata/preflight.json` — separate constraints, artifact identity, validation state, and unavailable deep checks for each declared role; +- `metadata/checksums.sha256` — stable SHA-256 entries for materialized outputs and metadata. + +EPUB and KEPUB remain ordinary optional profile targets and use the e-book capabilities described in [EPUB and KEPUB derivatives](ebook-derivatives.md). No retailer or provider is embedded in the publication model. + +## Hygiene and release approval + +Hygiene runs before validation and materialization. Blocking secret, protected-reference, rights, or approval findings prevent affected artifacts from entering the release tree. Candidate and proof roles remain explicit in the contract and manifest even when the complete run is partial. + +## Resume and rollback + +Use `--resume` to reuse checkpoints only when source, plan, configuration, and toolchain fingerprints remain compatible: + +```bash +renderflow build --config renderflow.yaml --profile magazine --resume +``` + +For rollback, retain the canonical source/spec plus `renderflow-run.json`, `metadata/provenance.json`, and `metadata/checksums.sha256` from the desired release. Restore that source revision and rebuild with the recorded toolchain. Never rename a failed or partial run into an approved release; correct the contract or source and create a new evidenced run. diff --git a/docs/user-guide/spec-v2-reference.md b/docs/user-guide/spec-v2-reference.md index c84a29b..69254fd 100644 --- a/docs/user-guide/spec-v2-reference.md +++ b/docs/user-guide/spec-v2-reference.md @@ -16,6 +16,7 @@ Spec v2 describes source intent, derivative selection, execution policy, and det | `hygiene` | `object` | no | `{}` | | `output` | `outputLayout` | no | — | | `profiles` | `object` | no | `{}` | +| `publication` | `object` | no | — | | `schema` | `"renderflow/v2"` | yes | — | | `sources` | `array` | yes | — | | `targets` | `targetSelection` | yes | — | @@ -37,6 +38,45 @@ Spec v2 describes source intent, derivative selection, execution policy, and det | `role` | `string` / `null` | no | — | | `uri` | `string` / `null` | no | — | +## Publication contract + +| Field | Type | Required | Default | +| --- | --- | --- | --- | +| `accessibility` | `accessibilityMetadata` | no | — | +| `artwork` | `array` | no | `[]` | +| `asset_policy` | `string` / `null` | no | — | +| `canonical_url` | `string` / `null` | no | — | +| `color_policy` | `string` / `null` | no | — | +| `contributors` | `array` | no | `[]` | +| `extensions` | `object` | no | `{}` | +| `font_policy` | `string` / `null` | no | — | +| `geometry` | `pageGeometry` | yes | — | +| `identifiers` | `object` | no | `{}` | +| `issue_id` | `string` | yes | — | +| `issue_number` | `string` / `null` | no | — | +| `language` | `string` | yes | — | +| `output_roles` | `object` | no | `{}` | +| `publication` | `string` | yes | — | +| `publication_date` | `string` | yes | — | +| `rights` | `publicationRights` | no | — | +| `schema` | `"renderflow.publication/v1"` | no | `"renderflow.publication/v1"` | +| `series` | `string` / `null` | no | — | +| `status` | `draft` / `reviewed` / `approved` / `released` | no | `"draft"` | +| `subtitle` | `string` / `null` | no | — | +| `title` | `string` | yes | — | + +## Publication output-role constraints + +| Field | Type | Required | Default | +| --- | --- | --- | --- | +| `color_policy` | `string` / `null` | no | — | +| `format` | `string` | yes | — | +| `geometry` | `object` | no | — | +| `minimum_image_dpi` | `integer` / `null` | no | — | +| `require_embedded_fonts` | `boolean` | no | `false` | +| `stage` | `string` | no | `""` | +| `validators` | `array` | no | `[]` | + ## Target selection | Field | Type | Required | Default | diff --git a/examples/magazine/assets/cover.svg b/examples/magazine/assets/cover.svg new file mode 100644 index 0000000..e22a719 --- /dev/null +++ b/examples/magazine/assets/cover.svg @@ -0,0 +1,11 @@ + + Synthetic Issue Zero cover + Indigo and cream geometric blocks surrounding the title The Small Systems Review. + + + + + THE SMALL SYSTEMS + REVIEW + SYNTHETIC ISSUE ZERO + diff --git a/examples/magazine/issue.md b/examples/magazine/issue.md new file mode 100644 index 0000000..46cae7a --- /dev/null +++ b/examples/magazine/issue.md @@ -0,0 +1,17 @@ +--- +title: The Small Systems Review +subtitle: Synthetic Issue Zero +lang: en-US +--- + +# Small tools, durable work + +This redistribution-safe synthetic issue demonstrates a reviewed editorial source without third-party text, trademarks, or artwork. + +## A field note + +Good publication systems preserve the source, explain every derivative, and make release approval explicit. + +## Colophon + +Written and illustrated with geometric placeholder assets for the Renderflow example suite. diff --git a/examples/magazine/renderflow.yaml b/examples/magazine/renderflow.yaml new file mode 100644 index 0000000..f073979 --- /dev/null +++ b/examples/magazine/renderflow.yaml @@ -0,0 +1,93 @@ +schema: renderflow/v2 +sources: + - id: issue-zero + role: editorial-source + path: issue.md + format: markdown +publication: + schema: renderflow.publication/v1 + publication: The Small Systems Review + series: Demonstration Editions + issue_id: small-systems-000 + issue_number: "0" + title: The Small Systems Review + subtitle: Synthetic Issue Zero + contributors: + - name: Renderflow Contributors + role: editorial + publication_date: "2026-09-11" + status: approved + language: en-US + artwork: + - role: cover + path: assets/cover.svg + alt_text: Indigo and cream geometric cover for The Small Systems Review, Synthetic Issue Zero. + approval_reference: synthetic-fixture-approval + geometry: + width: 210 + height: 297 + unit: mm + margin: 15 + bleed: 3 + safe_area: 5 + color_policy: print-cmyk-with-screen-srgb-derivatives + font_policy: embed-or-block-release + asset_policy: approved-local-assets-only + rights: + license: CC0-1.0 + rights_holder: Renderflow Contributors + approval_reference: synthetic-fixture-approval + reviewed: true + accessibility: + summary: Structured text with declared language and alternative text requirements. + access_modes: [textual, visual] + hazards: [none] + output_roles: + print/press: + format: pdf + stage: release + geometry: { width: 210, height: 297, unit: mm, bleed: 3, safe_area: 5 } + color_policy: cmyk + minimum_image_dpi: 300 + require_embedded_fonts: true + validators: [validator.core.pdf] + print/proof: + format: pdf + stage: candidate + geometry: { width: 210, height: 297, unit: mm } + color_policy: srgb + minimum_image_dpi: 144 + validators: [validator.core.pdf] + digital/screen: + format: pdf + stage: release + color_policy: srgb + validators: [validator.core.pdf] +hygiene: + magazine-release: + audience: public + metadata: + allow: [dc.title, dc.creator, dc.language, dc.rights] + deny: [exif, xmp, iptc, geolocation, filesystem.*] + secrets: + enabled: true + block: true + protected_references: + terms: [Example Protected Franchise] + block: true + rights: + required: true + license: CC0-1.0 + rights_holder: Renderflow Contributors + approval_reference: synthetic-fixture-approval + reviewed: true +targets: + exact: + - id: default.web + role: digital/web/index + format: html +execution: + hygiene_policy: magazine-release +output: + bundle_root: release + naming_template: "{target.role}.{ext}" diff --git a/mkdocs.yml b/mkdocs.yml index 64501c8..86ed163 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -71,6 +71,7 @@ nav: - Tool Registry: user-guide/tool-registry.md - Adapter Ecosystem: user-guide/adapter-ecosystem.md - EPUB and KEPUB Derivatives: user-guide/ebook-derivatives.md + - Magazine Publications: user-guide/magazine-publications.md - Super Resolution: user-guide/super-resolution.md - Upscayl Models: user-guide/upscayl-models.md - Pipelines: user-guide/pipelines.md diff --git a/schemas/renderflow-v2.schema.json b/schemas/renderflow-v2.schema.json index a742988..d1ea826 100644 --- a/schemas/renderflow-v2.schema.json +++ b/schemas/renderflow-v2.schema.json @@ -1,5 +1,31 @@ { "$defs": { + "accessibilityMetadata": { + "additionalProperties": false, + "properties": { + "access_modes": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "hazards": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "summary": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "allowDeny": { "additionalProperties": false, "properties": { @@ -279,6 +305,49 @@ }, "type": "object" }, + "pageGeometry": { + "additionalProperties": false, + "properties": { + "bleed": { + "minimum": 0, + "type": [ + "number", + "null" + ] + }, + "height": { + "exclusiveMinimum": 0, + "type": "number" + }, + "margin": { + "minimum": 0, + "type": [ + "number", + "null" + ] + }, + "safe_area": { + "minimum": 0, + "type": [ + "number", + "null" + ] + }, + "unit": { + "default": "mm", + "type": "string" + }, + "width": { + "exclusiveMinimum": 0, + "type": "number" + } + }, + "required": [ + "width", + "height" + ], + "type": "object" + }, "profile": { "additionalProperties": false, "properties": { @@ -408,6 +477,269 @@ }, "type": "object" }, + "publicationAsset": { + "additionalProperties": false, + "properties": { + "alt_text": { + "type": [ + "string", + "null" + ] + }, + "approval_reference": { + "type": [ + "string", + "null" + ] + }, + "path": { + "minLength": 1, + "type": "string" + }, + "role": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "role", + "path" + ], + "type": "object" + }, + "publicationContract": { + "additionalProperties": false, + "properties": { + "accessibility": { + "$ref": "#/$defs/accessibilityMetadata" + }, + "artwork": { + "default": [], + "items": { + "$ref": "#/$defs/publicationAsset" + }, + "type": "array" + }, + "asset_policy": { + "type": [ + "string", + "null" + ] + }, + "canonical_url": { + "type": [ + "string", + "null" + ] + }, + "color_policy": { + "type": [ + "string", + "null" + ] + }, + "contributors": { + "default": [], + "items": { + "$ref": "#/$defs/publicationContributor" + }, + "type": "array" + }, + "extensions": { + "default": {}, + "type": "object" + }, + "font_policy": { + "type": [ + "string", + "null" + ] + }, + "geometry": { + "$ref": "#/$defs/pageGeometry" + }, + "identifiers": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "type": "object" + }, + "issue_id": { + "minLength": 1, + "type": "string" + }, + "issue_number": { + "type": [ + "string", + "null" + ] + }, + "language": { + "minLength": 1, + "type": "string" + }, + "output_roles": { + "additionalProperties": { + "$ref": "#/$defs/publicationRoleConstraints" + }, + "default": {}, + "type": "object" + }, + "publication": { + "minLength": 1, + "type": "string" + }, + "publication_date": { + "minLength": 1, + "type": "string" + }, + "rights": { + "$ref": "#/$defs/publicationRights" + }, + "schema": { + "const": "renderflow.publication/v1", + "default": "renderflow.publication/v1" + }, + "series": { + "type": [ + "string", + "null" + ] + }, + "status": { + "default": "draft", + "enum": [ + "draft", + "reviewed", + "approved", + "released" + ] + }, + "subtitle": { + "type": [ + "string", + "null" + ] + }, + "title": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "publication", + "issue_id", + "title", + "publication_date", + "language", + "geometry" + ], + "type": "object" + }, + "publicationContributor": { + "additionalProperties": false, + "properties": { + "identifier": { + "type": [ + "string", + "null" + ] + }, + "name": { + "minLength": 1, + "type": "string" + }, + "role": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "name", + "role" + ], + "type": "object" + }, + "publicationRights": { + "additionalProperties": false, + "properties": { + "approval_reference": { + "type": [ + "string", + "null" + ] + }, + "license": { + "type": [ + "string", + "null" + ] + }, + "reviewed": { + "default": false, + "type": "boolean" + }, + "rights_holder": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "publicationRoleConstraints": { + "additionalProperties": false, + "properties": { + "color_policy": { + "type": [ + "string", + "null" + ] + }, + "format": { + "minLength": 1, + "type": "string" + }, + "geometry": { + "anyOf": [ + { + "$ref": "#/$defs/pageGeometry" + }, + { + "type": "null" + } + ] + }, + "minimum_image_dpi": { + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "require_embedded_fonts": { + "default": false, + "type": "boolean" + }, + "stage": { + "default": "", + "type": "string" + }, + "validators": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "format" + ], + "type": "object" + }, "requirements": { "additionalProperties": false, "properties": { @@ -803,6 +1135,16 @@ "default": {}, "type": "object" }, + "publication": { + "anyOf": [ + { + "$ref": "#/$defs/publicationContract" + }, + { + "type": "null" + } + ] + }, "schema": { "const": "renderflow/v2" }, diff --git a/scripts/generate_spec_v2_reference.py b/scripts/generate_spec_v2_reference.py index d2e60c8..97c3d8e 100644 --- a/scripts/generate_spec_v2_reference.py +++ b/scripts/generate_spec_v2_reference.py @@ -73,6 +73,13 @@ def main() -> None: ] lines.extend(render_properties("Top-level fields", schema)) lines.extend(render_properties("Source", definitions["source"])) + lines.extend(render_properties("Publication contract", definitions["publicationContract"])) + lines.extend( + render_properties( + "Publication output-role constraints", + definitions["publicationRoleConstraints"], + ) + ) lines.extend(render_properties("Target selection", definitions["targetSelection"])) lines.extend(render_properties("Execution policy", definitions["executionPolicy"])) lines.extend(render_properties("Output layout", definitions["outputLayout"]))