Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **Page-local attrs for integrations** — JSON-compatible `attrs` in frontmatter and selected YAML sidecars survive S3 publication and appear as optional `meta.attrs` in NAPI/core `renderPage()` responses. Frontmatter overlays top-level keys; nested values replace whole, null remains data, and attrs never inherit. Empty attrs are omitted. Authored nesting is bounded for manifest/cache transport; Rust JSON byte roundtrips preserve supported finite `f64` values. No built-in HTTP/viewer or rendering semantics change. See [Page Metadata](docs/metadata.md#attrs-page-local-integration-data).

### Changed

- **Breaking (pre-1.0, Rust source):** Public `rw_meta::Meta` literals must add `attrs: Default::default()` (or use `Meta::resolve`). S3 manifests remain version 1; upgrade readers before integrations rely on attrs, since older readers ignore and lose them on reserialization. Populated attrs increase manifest/cache payloads and selected-page conversion work. JavaScript numeric identifiers requiring exact large integers should be strings.

## [0.1.36] - 2026-09-07

### New Features
Expand Down
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ uses effective `Section.name`; explicitly named, kind-declaring roots opt in,
with eligibility included in the existing resolution fingerprint. Storage keeps
the flattened wire with optional, omitted-when-absent `name`; S3 stays version 1,
requiring readers to upgrade before publishers enable explicit names.
`Meta.attrs` is page-local JSON data: selected sidecar plus top-level frontmatter
overlay, whole nested replacement, literal null, no inheritance. Invalid source
attrs are discarded as a whole field. The same Arc carries attrs through cached
and virtual renders; flattened Document/structure/S3 wires omit empty attrs.
Only NAPI/core `renderPage().meta.attrs` exposes them (omitted empty), with normal
JS number precision. HTTP/viewer/search/navigation and renderer semantics stay
unchanged. Attrs add neither fetch paths nor render fingerprints nor refresh
guarantees; future renderer consumption must extend actual cache dependencies.
Upgrade S3 readers before consumers rely on attrs; old readers discard them on
reserialization. Public Rust Meta literals need `attrs: Default::default()`.

## Key Technical Details

Expand Down
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Publish the same markdown to Confluence pages or embed in Backstage with native
- **Status badges** — inline colored pill labels with Confluence status-macro parity
- **GitHub-style alerts** — `[!NOTE]`, `[!TIP]`, `[!WARNING]`, and more
- **Navigation and TOC** — automatic sidebar, breadcrumbs, and table of contents
- **Page metadata** — YAML frontmatter or sidecar files for titles, descriptions, navigation order, and explicit section/catalog and diagram names independent of URLs
- **Page metadata** — YAML frontmatter or sidecar files for titles, descriptions, navigation order, explicit section/catalog and diagram names independent of URLs, and page-local integration attrs exposed by NAPI/core
- **Confluence rendering** — produce publish-ready bundles (XHTML + diagrams) for any Confluence publishing tool
- **Backstage integration** — embed docs with native Backstage plugins

Expand Down
1 change: 1 addition & 0 deletions crates/rw-meta/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ workspace = true
pulldown-cmark = { workspace = true }
rw-sections = { workspace = true }
serde_yaml = { workspace = true }
serde_json = { workspace = true }
66 changes: 66 additions & 0 deletions crates/rw-meta/src/attrs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
use std::collections::BTreeMap;

use serde_json::Value as Json;
use serde_yaml::Value as Yaml;

// Leave room in serde_json's nesting budget for the surrounding wire envelope:
// root, document collection, document, and attrs map.
const MAX_VALUE_NESTING_DEPTH: usize = 127 - 4;

pub(crate) fn from_yaml(value: &Yaml) -> Result<BTreeMap<String, Json>, String> {
let Yaml::Mapping(values) = value else {
return Err("expected an attrs mapping".to_owned());
};
values
.iter()
.map(|entry| to_json_entry(entry, MAX_VALUE_NESTING_DEPTH))
.collect()
}

fn to_json_entry(
(key, value): (&Yaml, &Yaml),
remaining_containers: usize,
) -> Result<(String, Json), String> {
let Yaml::String(key) = key else {
return Err("expected string object keys".to_owned());
};
Ok((key.clone(), to_json(value, remaining_containers)?))
}

fn to_json(value: &Yaml, remaining_containers: usize) -> Result<Json, String> {
let remaining_containers = if matches!(value, Yaml::Sequence(_) | Yaml::Mapping(_)) {
remaining_containers
.checked_sub(1)
.ok_or_else(|| "attrs nesting exceeds the supported transport depth".to_owned())?
} else {
remaining_containers
};
match value {
Yaml::Null => Ok(Json::Null),
Yaml::Bool(value) => Ok(Json::Bool(*value)),
Yaml::String(value) => Ok(Json::String(value.clone())),
Yaml::Number(value) => {
let number = if let Some(value) = value.as_i64() {
Some(serde_json::Number::from(value))
} else if let Some(value) = value.as_u64() {
Some(serde_json::Number::from(value))
} else {
value.as_f64().and_then(serde_json::Number::from_f64)
};
number
.map(Json::Number)
.ok_or_else(|| "expected a finite JSON number".to_owned())
}
Yaml::Sequence(values) => values
.iter()
.map(|value| to_json(value, remaining_containers))
.collect::<Result<Vec<_>, _>>()
.map(Json::Array),
Yaml::Mapping(values) => values
.iter()
.map(|entry| to_json_entry(entry, remaining_containers))
.collect::<Result<serde_json::Map<String, Json>, String>>()
.map(Json::Object),
Yaml::Tagged(_) => Err("custom YAML tags are not supported in attrs".to_owned()),
}
}
44 changes: 40 additions & 4 deletions crates/rw-meta/src/fields.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use std::{borrow::Cow, collections::HashSet};
use std::{
borrow::Cow,
collections::{BTreeMap, HashSet},
};

use serde_yaml::{Mapping, Value};

Expand All @@ -12,13 +15,14 @@ pub(crate) struct MetaFields {
pub description: Option<String>,
pub pages: Option<Vec<String>>,
pub name: Option<String>,
pub attrs: BTreeMap<String, serde_json::Value>,
}

impl MetaFields {
/// Extract fields from one YAML source; a failing field drops only itself,
/// while a source that fails to parse (invalid YAML, non-mapping root)
/// contributes nothing and yields one `Severity::Error` diagnostic.
/// Extraction order is fixed (kind, namespace, title, description, pages, name)
/// Extraction order is fixed (kind, namespace, title, description, pages, name, attrs)
/// so diagnostics come back deterministic.
pub(crate) fn from_yaml_with_diagnostics(
yaml: &str,
Expand Down Expand Up @@ -70,23 +74,55 @@ impl MetaFields {
description: string_field(&mapping, "description", source, &mut diagnostics),
pages: pages_field(&mapping, source, &mut diagnostics),
name: name_field(&mapping, source, &mut diagnostics),
attrs: attrs_field(&mapping, source, &mut diagnostics),
};
(fields, diagnostics)
}

/// Merge `other` onto self. `other` fields win when Some.
/// Merge `other` onto self. Optional fields win when Some; attrs overlay by key.
pub(crate) fn merge(mut self, other: Self) -> Self {
self.kind = other.kind.or(self.kind);
self.namespace = other.namespace.or(self.namespace);
self.title = other.title.or(self.title);
self.description = other.description.or(self.description);
self.pages = other.pages.or(self.pages);
self.name = other.name.or(self.name);
self.attrs.extend(other.attrs);
self
}
}

const KNOWN_KEYS: [&str; 6] = ["kind", "namespace", "title", "description", "pages", "name"];
const KNOWN_KEYS: [&str; 7] = [
"kind",
"namespace",
"title",
"description",
"pages",
"name",
"attrs",
];

fn attrs_field(
mapping: &Mapping,
source: DiagnosticSource,
diagnostics: &mut Vec<Diagnostic>,
) -> BTreeMap<String, serde_json::Value> {
let Some(value) = mapping.get("attrs") else {
return BTreeMap::new();
};
match crate::attrs::from_yaml(value) {
Ok(attrs) => attrs,
Err(message) => {
diagnostics.push(Diagnostic {
source,
field: Some("attrs".to_owned()),
severity: Severity::Warning,
message,
});
BTreeMap::new()
}
}
}

fn normalize_known_keys(mapping: &Mapping) -> Result<Cow<'_, Mapping>, &'static str> {
let mut seen = HashSet::new();
Expand Down
Loading
Loading