diff --git a/CLAUDE.md b/CLAUDE.md index 4d2b945..1752907 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,9 +65,10 @@ When `children` is provided, it **takes over** rendering entirely — the root d Renderers emit semantic HTML with `data-gemara-*` attributes and no styling. The attribute taxonomy is the public API for CSS: - `data-gemara-artifact="ControlCatalog"` on the root `
`. -- `data-gemara-part="header" | "groups" | "group" | "control" | "control-id" | "control-title" | "objective" | "requirements" | "requirement" | "applicability" | "mappings" | "control-list"` for structural slots. +- `data-gemara-part="header" | "groups" | "group" | "control" | "control-id" | "control-title" | "objective" | "requirements" | "requirement" | "applicability" | "references" | "references-summary" | "mappings" | "control-list"` for structural slots. - `data-gemara-control-id`, `data-gemara-group-id`, `data-gemara-requirement-id`, `data-gemara-id` for stable selectors. -- `data-gemara-mappings-label="guidelines" | "threats"` to distinguish mapping sections. +- `data-gemara-part="references"` is an *uncontrolled* native `
` (collapsed by default, no `open`) wrapping a control/threat/guideline's mapping sections, with `data-gemara-part="references-summary"` on its `` ("References to Other Documents"). It carries no client JS — that's why it can live in the server-component-clean renderers (unlike `CollapsibleGroup`, whose `useState` forces it into `/interactive`). Consumers style open/closed via the native `details[open]` selector. +- `data-gemara-mappings-label="guidelines" | "threats" | "principles" | "capabilities" | "vectors"` to distinguish mapping sections (each nested inside `references`). - `data-gemara-ref="artifact" | "entry" | "mapping-reference"` + `data-gemara-ref-id` on resolver output. - `data-gemara-prose=""` on the wrapper element emitted by the `Prose` primitive (text fields rendered as plain text with `white-space: pre-wrap`). - `data-gemara-part="format-tabs" | "format-tablist" | "format-tab" | "format-panel" | "format-code"` on the `FormatTabs` interactive viewer, with `data-gemara-tab-id` on tabs/panels, `data-gemara-selected=""` on the active tab, and `data-gemara-language` on the code `
`. `FormatTabs` does no conversion — the consumer supplies the Preview node and pre-converted (gemaraconv, server-side) format strings as props.
diff --git a/README.md b/README.md
index 8b305db..3fdc592 100644
--- a/README.md
+++ b/README.md
@@ -65,9 +65,10 @@ The library ships no CSS. The public styling contract is the set of `data-gemara
 
 - `data-gemara-artifact="ControlCatalog" | "GuidanceCatalog" | "CapabilityCatalog" | "PrincipleCatalog" | "ThreatCatalog" | "VectorCatalog"` on each renderer's root `
`. - `data-gemara-id` — the artifact `metadata.id`. -- `data-gemara-part="header" | "title" | "meta" | "groups" | "group" | "control" | "guideline" | "capability" | "principle" | "threat" | "vector" | "control-id" | "control-title" | "guideline-id" | "guideline-title" | "capability-id" | "capability-title" | "principle-id" | "principle-title" | "threat-id" | "threat-title" | "vector-id" | "vector-title" | "objective" | "rationale" | "description" | "requirements" | "requirement" | "applicability" | "actors" | "actor" | "mappings" | "control-list" | "guideline-list" | "capability-list" | "principle-list" | "threat-list" | "vector-list" | "front-matter" | "extends"` for structural slots. +- `data-gemara-part="header" | "title" | "meta" | "groups" | "group" | "control" | "guideline" | "capability" | "principle" | "threat" | "vector" | "control-id" | "control-title" | "guideline-id" | "guideline-title" | "capability-id" | "capability-title" | "principle-id" | "principle-title" | "threat-id" | "threat-title" | "vector-id" | "vector-title" | "objective" | "rationale" | "description" | "requirements" | "requirement" | "applicability" | "actors" | "actor" | "references" | "references-summary" | "mappings" | "control-list" | "guideline-list" | "capability-list" | "principle-list" | "threat-list" | "vector-list" | "front-matter" | "extends"` for structural slots. - `data-gemara-control-id`, `data-gemara-guideline-id`, `data-gemara-capability-id`, `data-gemara-principle-id`, `data-gemara-threat-id`, `data-gemara-vector-id`, `data-gemara-group-id`, `data-gemara-requirement-id` for stable selectors. -- `data-gemara-mappings-label="guidelines" | "threats" | "principles" | "capabilities" | "vectors"` on mapping sections. +- `data-gemara-part="references"` is a native `
` (collapsed by default) wrapping a control/threat/guideline's cross-document mapping sections, with `data-gemara-part="references-summary"` on its `` ("References to Other Documents"). It carries no JavaScript — style the open/closed states with the native `details[open]` selector. +- `data-gemara-mappings-label="guidelines" | "threats" | "principles" | "capabilities" | "vectors"` on mapping sections (nested inside `references`). - `data-gemara-ref="artifact" | "entry" | "mapping-reference"` + `data-gemara-ref-id` on resolver output. - `data-gemara-prose=""` on the `Prose` wrapper element (plain-text fields). - `CollapsibleGroup` (interactive) emits `data-gemara-part="collapsible" | "collapsible-trigger" | "collapsible-content"`, plus `data-gemara-open=""` on the wrapper when expanded. @@ -121,6 +122,7 @@ import { ControlCatalog } from "@gemara/react/control-catalog"; aria-label="Catalog formats" tabs={[ { id: "preview", label: "Preview", preview: }, + { id: "yaml", label: "YAML", language: "yaml", content: rawYaml }, { id: "markdown", label: "Markdown", language: "markdown", content: markdown }, { id: "oscal", label: "OSCAL", language: "json", content: oscalJson }, ]} @@ -129,6 +131,14 @@ import { ControlCatalog } from "@gemara/react/control-catalog"; Each tab is either a `preview` node (rendered as-is) or `content` text (rendered in a `
` with `data-gemara-language` for your highlighter — the library ships no highlighting). A tab with neither is the seam for loading/empty states: pass `preview={}` while a conversion is still in flight.
 
+**`content` is rendered verbatim — formatting and tab selection are yours.** The component does no conversion, pretty-printing, or validation, so what you pass is exactly what renders:
+
+- **Pretty-print upstream.** A compact OSCAL string from the hub API (`{"catalog":{…}}`) shows as one flat line. Indent it before passing it in: `JSON.stringify(JSON.parse(oscalJson), null, 2)`. go-gemara's `oscalexport` CLI already emits indented JSON; a raw `fetch()` may not.
+- **You own which tabs exist.** There's no automatic format discovery. The raw **YAML** tab is just the source string you already parsed — no go-gemara round-trip needed (`content: rawYaml`). Only add an OSCAL or Markdown tab for artifact types that actually have a `gemaraconv` converter; for others, omit the tab.
+- **Validation lives upstream**, in go-gemara and the spec — `FormatTabs` will faithfully display malformed input. Use the empty-tab seam (`preview={}`) to surface a conversion that failed.
+
+A worked Astro example wiring all three (Preview + raw YAML + pre-converted OSCAL) lives in [`examples/astro/`](./examples/astro/src/components/MultiFormatViewer.tsx).
+
 It implements the ARIA tabs pattern with **automatic activation** — Arrow keys move focus and switch the panel in one step (Left/Right wrap; Home/End jump to first/last). Provide `aria-label` (or `aria-labelledby`) so the tablist has an accessible name — important when several viewers share a page. Because it holds tab state, it lives in `@gemara/react/interactive` and carries `"use client"`.
 
 ## React Server Components
diff --git a/examples/astro/src/components/MultiFormatViewer.tsx b/examples/astro/src/components/MultiFormatViewer.tsx
new file mode 100644
index 0000000..226a3aa
--- /dev/null
+++ b/examples/astro/src/components/MultiFormatViewer.tsx
@@ -0,0 +1,55 @@
+// SPDX-License-Identifier: Apache-2.0
+import { FormatTabs } from "@gemara/react/interactive";
+import { ControlCatalog } from "@gemara/react/control-catalog";
+import { GemaraProvider } from "@gemara/react/provider";
+import type { ControlCatalog as ControlCatalogData } from "@gemara/react/types";
+
+interface Props {
+  /** Parsed catalog, used for the rendered Preview tab. */
+  data: ControlCatalogData;
+  /** The raw source the page already read — no go-gemara round-trip needed. */
+  yaml: string;
+  /** Markdown projection produced server-side by go-gemara's `ToMarkdown`. */
+  markdown: string;
+  /** OSCAL projection produced server-side by go-gemara's `oscalexport`. */
+  oscal: string;
+}
+
+/**
+ * Composes `FormatTabs` inside a React island.
+ *
+ * The `preview` tab is a *rendered node* (``), which can't be
+ * passed as a prop across Astro's island boundary — only serializable props
+ * survive. So the page hands this wrapper plain data + strings, and the preview
+ * element is constructed here, client-side, inside React.
+ *
+ * Three lessons live in this file, not in the library:
+ *  1. `FormatTabs` renders `content` verbatim — any formatting is the
+ *     consumer's job. We pretty-print the OSCAL below.
+ *  2. The raw YAML tab is just the source string the page already has.
+ *  3. The component never converts or validates; go-gemara produced the OSCAL.
+ */
+export default function MultiFormatViewer({ data, yaml, markdown, oscal }: Props) {
+  // `JSON.stringify(JSON.parse(x), null, 2)` normalizes a compact API response
+  // into indented JSON. It's a no-op on already-indented OSCAL like ours, but
+  // shows the idiom for when the string arrives as one flat line from the hub.
+  const prettyOscal = JSON.stringify(JSON.parse(oscal), null, 2);
+
+  return (
+    
+      ,
+          },
+          { id: "yaml", label: "YAML", language: "yaml", content: yaml },
+          { id: "markdown", label: "Markdown", language: "markdown", content: markdown },
+          { id: "oscal", label: "OSCAL", language: "json", content: prettyOscal },
+        ]}
+      />
+    
+  );
+}
diff --git a/examples/astro/src/fixtures/good-ccc.md b/examples/astro/src/fixtures/good-ccc.md
new file mode 100644
index 0000000..e7ad382
--- /dev/null
+++ b/examples/astro/src/fixtures/good-ccc.md
@@ -0,0 +1,260 @@
+# FINOS Cloud Control Catalog
+
+Version: 2024.1
+
+_FINOS Cloud Control Catalog_ is a Gemara 0.20.0 ControlCatalog by FINOS (Human, `finos`).
+
+### Description
+
+FINOS CCC is an open standard project that describes consistent controls for
+compliant public cloud deployments in the financial services sector.
+
+### Requirement Applicability Groups
+
+The following groups are used to specify the circumstance within which an assessment requirement is mandated.
+
+- **tlp_clear** — TLP:Clear: Information may be shared without restriction.
+
+- **tlp_green** — TLP:Green: Information may be shared with partners and restricted to the
+organization.
+
+- **tlp_amber** — TLP:Amber: Information may be shared with partners and restricted to the
+organization.
+
+- **tlp_red** — TLP:Red: Information is restricted to the organization.
+
+_Summary: 5 control(s), 10 assessment requirement(s)._
+
+## Table of contents
+
+- [Data Protection](#data-protection)
+  - [CCC.C01: Prevent Unencrypted Requests](#ccc-c01-prevent-unencrypted-requests)
+  - [CCC.C06: Prevent Deployment in Restricted Regions](#ccc-c06-prevent-deployment-in-restricted-regions)
+  - [CCC.C08: Enable Multi-zone or Multi-region Data Replication](#ccc-c08-enable-multi-zone-or-multi-region-data-replication)
+  - [CCC.C09: Prevent Tampering, Deletion, or Unauthorized Access to Access Logs](#ccc-c09-prevent-tampering-deletion-or-unauthorized-access-to-access-logs)
+  - [CCC.C10: Prevent Data Replication to Destinations Outside of Defined
+Trust Perimeter
+](#ccc-c10-prevent-data-replication-to-destinations-outside-of-defined-trust-perimeter)
+
+## data-protection: Data Protection
+
+Data protection controls ensure that data is protected from unauthorized
+access, disclosure, and tampering. This includes encryption of data at
+rest and in transit, access controls, and data retention policies.
+
+### CCC.C01: Prevent Unencrypted Requests
+
+**Objective**
+
+Ensure that all communications are encrypted in transit to protect data
+integrity and confidentiality.
+
+#### Guidelines
+
+This control aids in the application of the following guidelines:
+
+| Source | References |
+| :--- | :--- |
+| **CSF** | PR.DS-02 — Data-in-transit is protected |
+| **CCM** | IVS-03 · IVS-07 |
+| **ISO-27001** | 2013 A.13.1.1 — This control is closely related to 2013 A.13.1.1. |
+| **NIST-800-53** | SC-8 · SC-13 |
+
+#### Threats
+
+This control aids in the mitigation of the following threats:
+
+| Source | References |
+| :--- | :--- |
+| **CCC** | CCC.TH02 — Data is Intercepted in Transit |
+
+#### CCC.C01.TR01
+
+When a port is exposed for non-SSH network traffic, all traffic MUST
+include a TLS handshake AND be encrypted using TLS 1.2 or higher.
+
+**Applicability:** tlp_clear, tlp_green, tlp_amber, tlp_red
+
+#### CCC.C01.TR02
+
+When a port is exposed for SSH network traffic, all traffic MUST
+include a SSH handshake AND be encrypted using SSHv2 or higher.
+
+**Applicability:** tlp_clear, tlp_green, tlp_amber, tlp_red
+
+### CCC.C06: Prevent Deployment in Restricted Regions
+
+**Objective**
+
+Ensure that resources are not provisioned or deployed in
+geographic regions or cloud availability zones that have been
+designated as restricted or prohibited, to comply with
+regulatory requirements and reduce exposure to geopolitical
+risks.
+
+#### Guidelines
+
+This control aids in the application of the following guidelines:
+
+| Source | References |
+| :--- | :--- |
+| **CCM** | DSI-06 — This control is closely related to DSI-06. · DSI-08 — This control is closely related to DSI-08. |
+| **ISO-27001** | 2013 A.11.1.1 — This control is closely related to 2013 A.11.1.1. |
+| **NIST-800-53** | AC-6 — This control is closely related to AC-6. |
+| **CSF** | PR.DS-1 — Data-at-rest is protected |
+
+#### Threats
+
+This control aids in the mitigation of the following threats:
+
+| Source | References |
+| :--- | :--- |
+| **CCC** | CCC.TH03 — Deployment Region Network is Untrusted |
+
+#### CCC.C06.TR01
+
+When a deployment request is made, the service MUST validate
+that the deployment region is not to a restricted or regions
+or availability zones.
+
+**Applicability:** tlp_clear, tlp_green, tlp_amber, tlp_red
+
+#### CCC.C06.TR02
+
+When a deployment request is made, the service MUST validate that
+replication of data, backups, and disaster recovery operations
+will not occur in restricted regions or availability zones.
+
+**Applicability:** tlp_clear, tlp_green, tlp_amber, tlp_red
+
+### CCC.C08: Enable Multi-zone or Multi-region Data Replication
+
+**Objective**
+
+Ensure that data is replicated across multiple
+zones or regions to protect against data loss due to hardware
+failures, natural disasters, or other catastrophic events.
+
+#### Guidelines
+
+This control aids in the application of the following guidelines:
+
+| Source | References |
+| :--- | :--- |
+| **CSF** | PR.DS-5 — Protections against data leaks are implemented |
+| **CCM** | BCR-08 — Backup |
+| **NIST-800-53** | CP-2 — Contingency plan · CP-10 — Information system recovery and reconstitution |
+
+#### Threats
+
+This control aids in the mitigation of the following threats:
+
+| Source | References |
+| :--- | :--- |
+| **CCC** | CCC.TH06 — Data is Lost or Corrupted |
+
+#### CCC.C08.TR01
+
+When data is stored, the service MUST ensure that data is
+replicated across multiple availability zones or regions.
+
+**Applicability:** tlp_green, tlp_amber, tlp_red
+
+#### CCC.C08.TR02
+
+When data is replicated across multiple zones or regions,
+the service MUST be able to verify the replication state,
+including the replication locations and data synchronization
+status.
+
+**Applicability:** tlp_green, tlp_amber, tlp_red
+
+### CCC.C09: Prevent Tampering, Deletion, or Unauthorized Access to Access Logs
+
+**Objective**
+
+Access logs should always be considered sensitive.
+Ensure that access logs are protected against unauthorized
+access, tampering, or deletion.
+
+#### Guidelines
+
+This control aids in the application of the following guidelines:
+
+| Source | References |
+| :--- | :--- |
+| **CCM** | LOG-02 — Audit log protection · LOG-04 — Audit log access and accountability · LOG-09 — Log protection |
+| **NIST-800-53** | AU-9 — Protection of audit information |
+
+#### Threats
+
+This control aids in the mitigation of the following threats:
+
+| Source | References |
+| :--- | :--- |
+| **CCC** | CCC.TH07 — Logs are Tampered with or Deleted · CCC.TH09 — Logs or Monitoring Data are Read by Unauthorized Users · CCC.TH04 — Data is Replicated to Untrusted or External Locations |
+
+#### CCC.C09.TR01
+
+When access logs are stored, the service MUST ensure that
+access logs cannot be accessed without proper authorization.
+
+**Applicability:** tlp_amber, tlp_red, tlp_green, tlp_clear
+
+#### CCC.C09.TR02
+
+When access logs are stored, the service MUST ensure that
+access logs cannot be modified without proper authorization.
+
+**Applicability:** tlp_amber, tlp_red, tlp_green, tlp_clear
+
+#### CCC.C09.TR03
+
+When access logs are stored, the service MUST ensure that
+access logs cannot be deleted without proper authorization.
+
+**Applicability:** tlp_amber, tlp_red, tlp_green, tlp_clear
+
+### CCC.C10: Prevent Data Replication to Destinations Outside of Defined
+Trust Perimeter
+
+**Objective**
+
+Prevent replication of data to untrusted destinations outside
+of defined trust perimeter. An untrusted destination is defined
+as a resource that exists outside of a specified trusted
+identity or network or data perimeter.
+
+#### Guidelines
+
+This control aids in the application of the following guidelines:
+
+| Source | References |
+| :--- | :--- |
+| **CSF** | PR.DS-5 — Protections against data leaks are implemented |
+| **CCM** | DSP-10 — Sensitive data transfer · DSP-19 — Data location |
+| **NIST-800-53** | AC-4 — Information flow enforcement |
+
+#### Threats
+
+This control aids in the mitigation of the following threats:
+
+| Source | References |
+| :--- | :--- |
+| **CCC** | CCC.TH04 — Data is Replicated to Untrusted or External Locations |
+
+#### CCC.C10.TR01
+
+When data is replicated, the service MUST ensure that
+replication is restricted to explicitly trusted destinations.
+
+**Applicability:** tlp_green, tlp_amber, tlp_red
+
+### Mapping References
+
+- **CCC** — FINOS Common Cloud Controls Threats (v2024.1)
+- **CSF** — NIST Cybersecurity Framework (v2.0)
+- **CCM** — Cloud Security Alliance Cloud Controls Matrix (v4.0)
+- **ISO-27001** — ISO/IEC 27001 (v2013)
+- **NIST-800-53** — NIST Special Publication 800-53 (vRev. 5)
+
diff --git a/examples/astro/src/fixtures/good-ccc.oscal.json b/examples/astro/src/fixtures/good-ccc.oscal.json
new file mode 100644
index 0000000..7e35e95
--- /dev/null
+++ b/examples/astro/src/fixtures/good-ccc.oscal.json
@@ -0,0 +1,358 @@
+{
+  "catalog": {
+    "groups": [
+      {
+        "class": "family",
+        "controls": [
+          {
+            "class": "data-protection",
+            "controls": [
+              {
+                "id": "CCC.C01.TR01",
+                "parts": [
+                  {
+                    "id": "CCC.C01.TR01_smt",
+                    "name": "statement",
+                    "prose": "When a port is exposed for non-SSH network traffic, all traffic MUST\ninclude a TLS handshake AND be encrypted using TLS 1.2 or higher.\n"
+                  },
+                  {
+                    "id": "CCC.C01.TR01_obj",
+                    "links": [
+                      {
+                        "href": "#CCC.C01.TR01_smt",
+                        "rel": "assessment-for"
+                      }
+                    ],
+                    "name": "assessment-objective"
+                  }
+                ],
+                "title": "CCC.C01.TR01"
+              },
+              {
+                "id": "CCC.C01.TR02",
+                "parts": [
+                  {
+                    "id": "CCC.C01.TR02_smt",
+                    "name": "statement",
+                    "prose": "When a port is exposed for SSH network traffic, all traffic MUST\ninclude a SSH handshake AND be encrypted using SSHv2 or higher.\n"
+                  },
+                  {
+                    "id": "CCC.C01.TR02_obj",
+                    "links": [
+                      {
+                        "href": "#CCC.C01.TR02_smt",
+                        "rel": "assessment-for"
+                      }
+                    ],
+                    "name": "assessment-objective"
+                  }
+                ],
+                "title": "CCC.C01.TR02"
+              }
+            ],
+            "id": "CCC.C01",
+            "links": [
+              {
+                "href": "https://example/versions/2024.1#ccc.c01",
+                "rel": "canonical"
+              }
+            ],
+            "parts": [
+              {
+                "id": "CCC.C01_smt",
+                "name": "statement",
+                "prose": "Ensure that all communications are encrypted in transit to protect data\nintegrity and confidentiality.\n"
+              }
+            ],
+            "title": "Prevent Unencrypted Requests"
+          },
+          {
+            "class": "data-protection",
+            "controls": [
+              {
+                "id": "CCC.C06.TR01",
+                "parts": [
+                  {
+                    "id": "CCC.C06.TR01_smt",
+                    "name": "statement",
+                    "prose": "When a deployment request is made, the service MUST validate\nthat the deployment region is not to a restricted or regions\nor availability zones.\n"
+                  },
+                  {
+                    "id": "CCC.C06.TR01_obj",
+                    "links": [
+                      {
+                        "href": "#CCC.C06.TR01_smt",
+                        "rel": "assessment-for"
+                      }
+                    ],
+                    "name": "assessment-objective"
+                  }
+                ],
+                "title": "CCC.C06.TR01"
+              },
+              {
+                "id": "CCC.C06.TR02",
+                "parts": [
+                  {
+                    "id": "CCC.C06.TR02_smt",
+                    "name": "statement",
+                    "prose": "When a deployment request is made, the service MUST validate that\nreplication of data, backups, and disaster recovery operations\nwill not occur in restricted regions or availability zones.\n"
+                  },
+                  {
+                    "id": "CCC.C06.TR02_obj",
+                    "links": [
+                      {
+                        "href": "#CCC.C06.TR02_smt",
+                        "rel": "assessment-for"
+                      }
+                    ],
+                    "name": "assessment-objective"
+                  }
+                ],
+                "title": "CCC.C06.TR02"
+              }
+            ],
+            "id": "CCC.C06",
+            "links": [
+              {
+                "href": "https://example/versions/2024.1#ccc.c06",
+                "rel": "canonical"
+              }
+            ],
+            "parts": [
+              {
+                "id": "CCC.C06_smt",
+                "name": "statement",
+                "prose": "Ensure that resources are not provisioned or deployed in\ngeographic regions or cloud availability zones that have been\ndesignated as restricted or prohibited, to comply with\nregulatory requirements and reduce exposure to geopolitical\nrisks.\n"
+              }
+            ],
+            "title": "Prevent Deployment in Restricted Regions"
+          },
+          {
+            "class": "data-protection",
+            "controls": [
+              {
+                "id": "CCC.C08.TR01",
+                "parts": [
+                  {
+                    "id": "CCC.C08.TR01_smt",
+                    "name": "statement",
+                    "prose": "When data is stored, the service MUST ensure that data is\nreplicated across multiple availability zones or regions.\n"
+                  },
+                  {
+                    "id": "CCC.C08.TR01_obj",
+                    "links": [
+                      {
+                        "href": "#CCC.C08.TR01_smt",
+                        "rel": "assessment-for"
+                      }
+                    ],
+                    "name": "assessment-objective"
+                  }
+                ],
+                "title": "CCC.C08.TR01"
+              },
+              {
+                "id": "CCC.C08.TR02",
+                "parts": [
+                  {
+                    "id": "CCC.C08.TR02_smt",
+                    "name": "statement",
+                    "prose": "When data is replicated across multiple zones or regions,\nthe service MUST be able to verify the replication state,\nincluding the replication locations and data synchronization\nstatus.\n"
+                  },
+                  {
+                    "id": "CCC.C08.TR02_obj",
+                    "links": [
+                      {
+                        "href": "#CCC.C08.TR02_smt",
+                        "rel": "assessment-for"
+                      }
+                    ],
+                    "name": "assessment-objective"
+                  }
+                ],
+                "title": "CCC.C08.TR02"
+              }
+            ],
+            "id": "CCC.C08",
+            "links": [
+              {
+                "href": "https://example/versions/2024.1#ccc.c08",
+                "rel": "canonical"
+              }
+            ],
+            "parts": [
+              {
+                "id": "CCC.C08_smt",
+                "name": "statement",
+                "prose": "Ensure that data is replicated across multiple\nzones or regions to protect against data loss due to hardware\nfailures, natural disasters, or other catastrophic events.\n"
+              }
+            ],
+            "title": "Enable Multi-zone or Multi-region Data Replication"
+          },
+          {
+            "class": "data-protection",
+            "controls": [
+              {
+                "id": "CCC.C09.TR01",
+                "parts": [
+                  {
+                    "id": "CCC.C09.TR01_smt",
+                    "name": "statement",
+                    "prose": "When access logs are stored, the service MUST ensure that\naccess logs cannot be accessed without proper authorization.\n"
+                  },
+                  {
+                    "id": "CCC.C09.TR01_obj",
+                    "links": [
+                      {
+                        "href": "#CCC.C09.TR01_smt",
+                        "rel": "assessment-for"
+                      }
+                    ],
+                    "name": "assessment-objective"
+                  }
+                ],
+                "title": "CCC.C09.TR01"
+              },
+              {
+                "id": "CCC.C09.TR02",
+                "parts": [
+                  {
+                    "id": "CCC.C09.TR02_smt",
+                    "name": "statement",
+                    "prose": "When access logs are stored, the service MUST ensure that\naccess logs cannot be modified without proper authorization.\n"
+                  },
+                  {
+                    "id": "CCC.C09.TR02_obj",
+                    "links": [
+                      {
+                        "href": "#CCC.C09.TR02_smt",
+                        "rel": "assessment-for"
+                      }
+                    ],
+                    "name": "assessment-objective"
+                  }
+                ],
+                "title": "CCC.C09.TR02"
+              },
+              {
+                "id": "CCC.C09.TR03",
+                "parts": [
+                  {
+                    "id": "CCC.C09.TR03_smt",
+                    "name": "statement",
+                    "prose": "When access logs are stored, the service MUST ensure that\naccess logs cannot be deleted without proper authorization.\n"
+                  },
+                  {
+                    "id": "CCC.C09.TR03_obj",
+                    "links": [
+                      {
+                        "href": "#CCC.C09.TR03_smt",
+                        "rel": "assessment-for"
+                      }
+                    ],
+                    "name": "assessment-objective"
+                  }
+                ],
+                "title": "CCC.C09.TR03"
+              }
+            ],
+            "id": "CCC.C09",
+            "links": [
+              {
+                "href": "https://example/versions/2024.1#ccc.c09",
+                "rel": "canonical"
+              }
+            ],
+            "parts": [
+              {
+                "id": "CCC.C09_smt",
+                "name": "statement",
+                "prose": "Access logs should always be considered sensitive.\nEnsure that access logs are protected against unauthorized\naccess, tampering, or deletion.\n"
+              }
+            ],
+            "title": "Prevent Tampering, Deletion, or Unauthorized Access to Access Logs"
+          },
+          {
+            "class": "data-protection",
+            "controls": [
+              {
+                "id": "CCC.C10.TR01",
+                "parts": [
+                  {
+                    "id": "CCC.C10.TR01_smt",
+                    "name": "statement",
+                    "prose": "When data is replicated, the service MUST ensure that\nreplication is restricted to explicitly trusted destinations.\n"
+                  },
+                  {
+                    "id": "CCC.C10.TR01_obj",
+                    "links": [
+                      {
+                        "href": "#CCC.C10.TR01_smt",
+                        "rel": "assessment-for"
+                      }
+                    ],
+                    "name": "assessment-objective"
+                  }
+                ],
+                "title": "CCC.C10.TR01"
+              }
+            ],
+            "id": "CCC.C10",
+            "links": [
+              {
+                "href": "https://example/versions/2024.1#ccc.c10",
+                "rel": "canonical"
+              }
+            ],
+            "parts": [
+              {
+                "id": "CCC.C10_smt",
+                "name": "statement",
+                "prose": "Prevent replication of data to untrusted destinations outside\nof defined trust perimeter. An untrusted destination is defined\nas a resource that exists outside of a specified trusted\nidentity or network or data perimeter.\n"
+              }
+            ],
+            "title": "Prevent Data Replication to Destinations Outside of Defined\\nTrust Perimeter"
+          }
+        ],
+        "id": "data-protection",
+        "title": "Data protection controls ensure that data is protected from unauthorized\\naccess, disclosure, and tampering. This includes encryption of data at\\nrest and in transit, access controls, and data retention policies.\\n"
+      }
+    ],
+    "metadata": {
+      "last-modified": "2026-06-03T03:00:46.306495-05:00",
+      "links": [
+        {
+          "href": "https://example/versions/2024.1#",
+          "rel": "canonical"
+        }
+      ],
+      "oscal-version": "1.1.3",
+      "parties": [
+        {
+          "name": "FINOS",
+          "type": "person",
+          "uuid": "c25bdc92-fe3f-441f-ab96-5a3ed257a28c"
+        }
+      ],
+      "responsible-parties": [
+        {
+          "party-uuids": [
+            "c25bdc92-fe3f-441f-ab96-5a3ed257a28c"
+          ],
+          "role-id": "author"
+        }
+      ],
+      "roles": [
+        {
+          "description": "Author and owner of the document",
+          "id": "author",
+          "title": "Author"
+        }
+      ],
+      "title": "FINOS Cloud Control Catalog",
+      "version": "2024.1"
+    },
+    "uuid": "0414293d-6afd-4d7c-afa2-f9fd4c0a8e99"
+  }
+}
\ No newline at end of file
diff --git a/examples/astro/src/pages/index.astro b/examples/astro/src/pages/index.astro
index d862d89..34038ce 100644
--- a/examples/astro/src/pages/index.astro
+++ b/examples/astro/src/pages/index.astro
@@ -7,6 +7,18 @@ import { ControlCatalog } from "@gemara/react/control-catalog";
 import { GemaraProvider } from "@gemara/react/provider";
 import { isControlCatalog } from "@gemara/react";
 import CollapsibleControlGroup from "../components/CollapsibleControlGroup.tsx";
+import MultiFormatViewer from "../components/MultiFormatViewer.tsx";
+// The OSCAL projection is produced server-side by go-gemara, never in the
+// browser. Pre-generated with:
+//   (cd ../../../go-gemara && go run ./cmd/oscalexport catalog \
+//      ../gemara/test/test-data/good-ccc.yaml --output catalog.json)
+// then committed as a fixture. `?raw` hands us the file as an opaque string —
+// exactly what a `fetch()` against the hub API would give you.
+import oscal from "../fixtures/good-ccc.oscal.json?raw";
+// The Markdown projection comes from go-gemara's `ControlCatalog(c).ToMarkdown`.
+// (gemaraconv has no CLI for it, so the fixture was generated with a one-off
+// program and committed.) Same `?raw` import — an opaque string we display.
+import markdown from "../fixtures/good-ccc.md?raw";
 
 // Load a fixture from the canonical gemara repo at build time. In a real app
 // this would come from the hub API or a content collection.
@@ -52,5 +64,21 @@ const firstGroupId = data.groups?.[0]?.id ?? "";
         groupId={firstGroupId}
       />
     )}
+
+    
+ +

Multi-format viewer (client:idle)

+

+ Preview (rendered from the structured data) + raw YAML, Markdown, and + OSCAL. The Markdown and OSCAL are pre-converted by go-gemara and passed in + as strings; the component does no conversion or validation. +

+ diff --git a/src/control-catalog/ControlCatalog.tsx b/src/control-catalog/ControlCatalog.tsx index 9c96e15..443742d 100644 --- a/src/control-catalog/ControlCatalog.tsx +++ b/src/control-catalog/ControlCatalog.tsx @@ -226,11 +226,16 @@ function ControlView({ control }: ControlViewProps) { {control["assessment-requirements"] && control["assessment-requirements"].length > 0 ? ( ) : null} - {control.guidelines && control.guidelines.length > 0 ? ( - - ) : null} - {control.threats && control.threats.length > 0 ? ( - + {(control.guidelines && control.guidelines.length > 0) || + (control.threats && control.threats.length > 0) ? ( + + {control.guidelines && control.guidelines.length > 0 ? ( + + ) : null} + {control.threats && control.threats.length > 0 ? ( + + ) : null} + ) : null}
); @@ -264,6 +269,17 @@ function RequirementList({ requirements }: RequirementListProps) { ); } +function References({ children }: { children: ReactNode }) { + return ( +
+ + References to Other Documents + + {children} +
+ ); +} + interface MappingsProps { label: string; mappings: MultiEntryMapping[]; @@ -317,5 +333,6 @@ export const ControlCatalog = Object.assign(ControlCatalogRoot, { Group: GroupView, Control: ControlView, Requirements: RequirementList, + References, Mappings, }); diff --git a/src/guidance-catalog/GuidanceCatalog.tsx b/src/guidance-catalog/GuidanceCatalog.tsx index ba0fb62..d8bf448 100644 --- a/src/guidance-catalog/GuidanceCatalog.tsx +++ b/src/guidance-catalog/GuidanceCatalog.tsx @@ -223,7 +223,9 @@ function GuidelineView({ guideline }: GuidelineViewProps) { ) : null} {guideline.principles && guideline.principles.length > 0 ? ( - + + + ) : null} {guideline.extends ? (

@@ -242,6 +244,17 @@ function GuidelineView({ guideline }: GuidelineViewProps) { ); } +function References({ children }: { children: ReactNode }) { + return ( +

+ + References to Other Documents + + {children} +
+ ); +} + interface MappingsProps { label: string; mappings: MultiEntryMapping[]; @@ -290,5 +303,6 @@ export const GuidanceCatalog = Object.assign(GuidanceCatalogRoot, { Groups, Group: GroupView, Guideline: GuidelineView, + References, Mappings, }); diff --git a/src/threat-catalog/ThreatCatalog.tsx b/src/threat-catalog/ThreatCatalog.tsx index 50792c1..815e074 100644 --- a/src/threat-catalog/ThreatCatalog.tsx +++ b/src/threat-catalog/ThreatCatalog.tsx @@ -219,11 +219,16 @@ function ThreatView({ threat }: ThreatViewProps) { {threat.actors && threat.actors.length > 0 ? ( ) : null} - {threat.capabilities && threat.capabilities.length > 0 ? ( - - ) : null} - {threat.vectors && threat.vectors.length > 0 ? ( - + {(threat.capabilities && threat.capabilities.length > 0) || + (threat.vectors && threat.vectors.length > 0) ? ( + + {threat.capabilities && threat.capabilities.length > 0 ? ( + + ) : null} + {threat.vectors && threat.vectors.length > 0 ? ( + + ) : null} + ) : null}
); @@ -248,6 +253,17 @@ function Actors({ actors }: ActorsProps) { ); } +function References({ children }: { children: ReactNode }) { + return ( +
+ + References to Other Documents + + {children} +
+ ); +} + interface MappingsProps { label: string; mappings: MultiEntryMapping[]; @@ -301,5 +317,6 @@ export const ThreatCatalog = Object.assign(ThreatCatalogRoot, { Group: GroupView, Threat: ThreatView, Actors, + References, Mappings, }); diff --git a/tests/ControlCatalog.test.tsx b/tests/ControlCatalog.test.tsx index 28c48e1..d477da9 100644 --- a/tests/ControlCatalog.test.tsx +++ b/tests/ControlCatalog.test.tsx @@ -79,6 +79,29 @@ describe("ControlCatalog", () => { } }); + it("groups guideline and threat mappings in a collapsed References section", () => { + const { container } = render(); + const details = container.querySelector( + "details[data-gemara-part='references']", + ) as HTMLDetailsElement | null; + expect(details).not.toBeNull(); + // Collapsed by default: no `open` attribute, stays server-component-clean. + expect(details?.open).toBe(false); + const summary = details?.querySelector( + "summary[data-gemara-part='references-summary']", + ); + expect(summary?.textContent).toBe("References to Other Documents"); + // The existing mapping sections live inside the disclosure, not loose. + expect( + details?.querySelector("[data-gemara-part='mappings']"), + ).not.toBeNull(); + expect( + container.querySelector( + "details[data-gemara-part='references'] [data-gemara-mappings-label='guidelines']", + ), + ).not.toBeNull(); + }); + it("uses linkResolver from context for cross-references", () => { const Resolver = ({ children }: { children: React.ReactNode }) => ( { } }); + it("groups principle mappings in a collapsed References section", () => { + const { container } = render(); + const details = container.querySelector( + "details[data-gemara-part='references']", + ) as HTMLDetailsElement | null; + expect(details).not.toBeNull(); + expect(details?.open).toBe(false); + expect( + details?.querySelector("summary[data-gemara-part='references-summary']") + ?.textContent, + ).toBe("References to Other Documents"); + expect( + details?.querySelector("[data-gemara-mappings-label='principles']"), + ).not.toBeNull(); + }); + it("defaults the catalog title to

", () => { const { container } = render(); expect(container.querySelector("h1[data-gemara-part='title']")).not.toBeNull(); diff --git a/tests/ThreatCatalog.test.tsx b/tests/ThreatCatalog.test.tsx index e796213..bfbae80 100644 --- a/tests/ThreatCatalog.test.tsx +++ b/tests/ThreatCatalog.test.tsx @@ -108,6 +108,22 @@ describe("ThreatCatalog", () => { } }); + it("groups capability and vector mappings in a collapsed References section", () => { + const { container } = render(); + const details = container.querySelector( + "details[data-gemara-part='references']", + ) as HTMLDetailsElement | null; + expect(details).not.toBeNull(); + expect(details?.open).toBe(false); + expect( + details?.querySelector("summary[data-gemara-part='references-summary']") + ?.textContent, + ).toBe("References to Other Documents"); + expect( + details?.querySelector("[data-gemara-mappings-label='capabilities']"), + ).not.toBeNull(); + }); + it("uses linkResolver from context for capability/vector mappings", () => { const Resolver = ({ children }: { children: React.ReactNode }) => (