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
2 changes: 1 addition & 1 deletion apps/navigator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"dependencies": {
"react": "^19.3.0",
"react-dom": "^19.3.0",
"structured-context": "^0.22.1",
"structured-context": "^0.23.0",
"structured-context-map-views": "workspace:*"
},
"devDependencies": {
Expand Down
15 changes: 6 additions & 9 deletions apps/navigator/src/UnattachedAnnotationPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
import type { UnattachedAnnotation } from 'structured-context-map-views';
import { AnnotationList } from './NodeDetails';

/**
* Validation results with no node on the map: global rules, and nodes that failed their schema
* (those aren't in the graph). Grouped by node id, or under "Space" when the result has none.
*/
/** Validation results with no node on the map, grouped by node or file. */
export function UnattachedAnnotationPanel({
unattached,
onClose,
}: {
unattached: readonly UnattachedAnnotation[];
onClose: () => void;
}) {
const groups = Map.groupBy(unattached, (u) => u.nodeId ?? '');
const groups = Map.groupBy(unattached, (u) => u.nodeId ?? u.location?.file ?? '');
return (
<aside className="details" aria-label="Validation results not on a node">
<div className="details-head">
Expand All @@ -21,10 +18,10 @@ export function UnattachedAnnotationPanel({
×
</button>
</div>
{[...groups].map(([nodeId, items]) => (
<section key={nodeId}>
<h3>{nodeId || 'Space'}</h3>
{items[0]?.line !== undefined && <p className="source muted">line {items[0].line}</p>}
{[...groups].map(([name, items]) => (
<section key={name}>
<h3>{name || 'Space'}</h3>
{items[0]?.location?.line !== undefined && <p className="source muted">line {items[0].location.line}</p>}
<AnnotationList annotations={items} />
</section>
))}
Expand Down
6 changes: 3 additions & 3 deletions apps/navigator/src/server/validate-space.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { type Config, createSpaceContext, nodeIssues, validateSpace } from 'structured-context/api';
import { type Config, createSpaceContext, validateSpace } from 'structured-context/api';
import type { ValidationInput } from 'structured-context-map-views';

/**
* Validate a space for the overlay. No judge is passed, so only expression rules run (AI-judged rules
* would cost money and are not requested here).
*/
export async function validateForOverlay(spaceName: string, config: Config): Promise<ValidationInput> {
const result = await validateSpace(createSpaceContext(spaceName, config));
return { issues: nodeIssues(result), ruleViolations: result.ruleViolations };
const { issues } = await validateSpace(createSpaceContext(spaceName, config));
return { issues };
}
10 changes: 5 additions & 5 deletions bun.lock

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

18 changes: 6 additions & 12 deletions docs/api.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# API

The library is pre-1.0: its exports may change between minor versions. Until the first release is published, hosts consume a local build (`bun run build` in `packages/map-views`) through a `file:` dependency on that folder.
The library is pre-1.0: its exports may change between minor versions.

| Entry point | Contents |
|---|---|
Expand All @@ -27,7 +27,7 @@ import 'structured-context-map-views/styles.css';
| `definition` | `MapViewDefinition` | A parsed definition |
| `graph` | `SpaceGraph` | The space to render, as structured-context's `assembleSpaceGraph` or `spaceGraphFrom` returns it |
| `values` | `ParameterValues` | Current parameter values, owned by the host. Missing values use defaults |
| `annotations` | `AnnotationIndex` | Validation results by node (see [Validation results](#validation-results)). Drawn only when the region's `nodes.validation` isn't `off` |
| `annotations` | `AnnotationIndex` | Validation results by node (see [Validation results](#validation-results)) |
| `onNodeSelect` | `(node: SpaceNode) => void` | Called when the reader selects a node |

`MapView` sizes to its container and scrolls within it.
Expand Down Expand Up @@ -61,7 +61,7 @@ Map views take structured-context's `SpaceGraph`. A Node host (such as the Obsid
"space": "name",
"schemaId": "sctx://…",
"graph": { "formatVersion": 1 },
"validation": { "nodeErrors": [], "hierarchyViolations": [], "ruleViolations": [] },
"validation": { "issues": [] },
"definitions": []
}
```
Expand All @@ -74,19 +74,13 @@ Map views take structured-context's `SpaceGraph`. A Node host (such as the Obsid

## Validation results

Hosts that validate a space can show the results on the nodes. Pass `annotationsFrom(input, graph)` each node's issues as `issues`, and optionally the `ruleViolations`. From a `ValidationResult`, that is `{ issues: nodeIssues(result), ruleViolations: result.ruleViolations }`. The output of `sctx validate --json` has both fields, so it can be passed as it is. It returns `{ annotations, unattached }`: an `AnnotationIndex` (results by node id) to pass to `MapView`, and the results with no node on the graph for the host to surface itself.
`annotationsFrom(input, graph)` attaches structured-context's validation issues to the graph's nodes. Pass it a `ValidationResult` or the output of `sctx validate --json` as it is. It returns the `annotations` to pass to `MapView`, and the `unattached` issues, which have no node in the graph. The definition's `nodes.validation` and `nodes.validationSources` choose which annotations show.

structured-context names a result's node by `nodeId`. A result is attached when that node is in the graph. It is `unattached` when it has no `nodeId` (a global-scope rule) or its node isn't in the graph, such as a node of an unknown type. Nodes with field errors stay in the graph, so their schema errors attach. An unattached result keeps its `nodeId` and, where known, its `line` and `column`.

Each annotation has a `severity` (`error`, `warning` or `info`), a `source` and a `message`. The source is structured-context's issue kind: `schema`, `duplicate`, `broken-link`, `hierarchy`, `rule`, `gated` or `content-link`. Severity comes from structured-context with each result. Which show, and the lowest severity drawn, are the definition's `nodes.validation` and `nodes.validationSources` settings.

### Showing the messages

The library draws the badge and leaves the messages to the host, because a message list takes its markup and styling from the host (the navigator's side panel and a Claude artifact's differ). The pattern is the same everywhere: keep the `annotations` index you passed to `MapView`, and when a card is selected list `annotations.get(node.id)`, one row per result with its `severity`, its `source` (plus `ruleId` for rule results) and its `message`. List `unattached` separately, grouped by `nodeId`. `NodeAnnotation` and `UnattachedAnnotation` are exported for typing. See `AnnotationList` in `apps/navigator/src/NodeDetails.tsx`.
The library draws the badge, and the host lists the messages. See `AnnotationList` in `apps/navigator/src/NodeDetails.tsx`.

## Drawing a tree yourself

A host that cannot mount React, such as a Claude Code mod, can draw a tree from its projection. `buildTree(graph, regionId, dag)` returns a `Tree`: its `roots` and `orphans` are `TreeOccurrence`s, each with its `node`, its `children` and, for a node shown under several parents, `repeatOf`. `regionId` prefixes the occurrence keys. `dag` is `repeat` (the default) or `cross-edge`, as in the tree layout's setting. Pair it with `annotationsFrom` for the validation results.
A host that cannot mount React, such as a Claude Code mod, can draw a tree from `buildTree(graph, regionId, dag)`, the tree layout's projection, with `annotationsFrom` for the validation results.

Importing only these from the package root pulls in neither React nor the definition parser (ajv, json5) when bundled.

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ flowchart LR

Hosts pass the library structured-context's `SpaceGraph`: nodes keyed by id, the main hierarchy, and every hierarchy and relationship edge with its metadata, readable in both directions (`parentEdges`, `childEdges`). The Obsidian plugin assembles it in-process; the navigator's server sends the wire format (`exportSpaceGraph`) and its browser rebuilds the graph (`spaceGraphFrom`). The graph keeps edges to nodes outside it; the helpers in `core/graph.ts` drop them, so a filtered space stays consistent.

Hosts can also pass validation results as an `AnnotationIndex` (`core/annotations.ts`): results by node id, built by `annotationsFrom` from structured-context's per-node issues (`nodeIssues`, or the `issues` of `sctx validate --json`). `MapView` narrows it by the region's `nodes.validation` and `nodes.validationSources` and provides the result to node cards through React context, so layouts don't thread it through. Every layout draws nodes with `NodeCard`, which draws the badge.
Hosts can also pass validation results as an `AnnotationIndex` (`core/annotations.ts`): results by node id, built by `annotationsFrom` from structured-context's validation issues. `MapView` narrows it by the region's `nodes.validation` and `nodes.validationSources` and provides the result to node cards through React context, so layouts don't thread it through. Every layout draws nodes with `NodeCard`, which draws the badge.

## Projections

Expand Down
2 changes: 1 addition & 1 deletion docs/definitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ A map view definition is a JSON5 file. Its format is a set of JSON Schemas publi
| `nodes.style` | | `card` (title and fields, the default) or `shape` (a compact marker with a tooltip) |
| `nodes.fields` | | Schema fields shown on cards |
| `nodes.validation` | | The lowest severity of validation result drawn on nodes: `off` (the default), `error`, `warning` or `info`. Results come from the host; with none supplied nothing is drawn |
| `nodes.validationSources` | | Which results are drawn: any of `schema`, `duplicate`, `broken-link`, `hierarchy`, `rule`, `gated`, `content-link` (default all) |
| `nodes.validationSources` | | Which results are drawn: any of structured-context's issue kinds, such as `schema`, `hierarchy` or `rule` (default all) |
| `edges.show` | | Whether connectors are drawn (default `true`) |

## Layouts
Expand Down
4 changes: 2 additions & 2 deletions packages/map-views/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"peerDependencies": {
"react": "^19.3.0",
"react-dom": "^19.3.0",
"structured-context": "^0.22.1"
"structured-context": "^0.23.0"
},
"dependencies": {
"ajv": "^8.20.0",
Expand All @@ -68,7 +68,7 @@
"@vitejs/plugin-react": "^6.1.1",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"structured-context": "^0.22.1",
"structured-context": "^0.23.0",
"tailwindcss": "^4.3.3",
"vite": "^8.3.2"
}
Expand Down
69 changes: 25 additions & 44 deletions packages/map-views/src/core/annotations.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { nodeIssues, RuleSeverity, SpaceGraph, SpaceNode, ValidationResult } from 'structured-context/api';
import type { ValidationIssueKind } from 'structured-context/types';
import type { RuleSeverity, SpaceGraph, SpaceNode, ValidationResult } from 'structured-context/api';
import type { SourceLocation, ValidationIssueKind } from 'structured-context/types';

// core doesn't export NodeId from `structured-context/api` yet; this derives it.
type NodeId = SpaceNode['id'];

/** How serious a result is: structured-context's rule severity, which every result carries. */
/** structured-context's severity. */
export type Severity = RuleSeverity;

/** The kind of check a result came from: structured-context's issue `kind`. */
/** structured-context's issue `kind`. */
export type AnnotationSource = ValidationIssueKind;

/** One validation result attached to a node. */
Expand All @@ -21,64 +21,45 @@ export type NodeAnnotation = {

/** A validation result with no node on the map to attach to. */
export type UnattachedAnnotation = NodeAnnotation & {
/** The node it concerns. Absent for a global-scope rule. A node that failed its schema isn't in the graph. */
nodeId?: NodeId;
/** Where in that node's file the result points, if known (1-indexed). */
line?: number;
column?: number;
location?: SourceLocation;
};

/** Annotations by the id of the node they apply to. */
export type AnnotationIndex = ReadonlyMap<NodeId, readonly NodeAnnotation[]>;

/**
* Validation results to attach: each node's issues, from structured-context's `nodeIssues(result)` or the
* `issues` of `sctx validate --json`, and the rule violations, of which only those without a node are read
* (the others are in `issues`). `sctx validate --json` output can be passed as it is.
*/
type Issue = ValidationResult['issues'][number];

/** A `ValidationResult`, or the output of `sctx validate --json`. */
export type ValidationInput = {
issues: Readonly<ReturnType<typeof nodeIssues>>;
ruleViolations?: ReadonlyArray<
Pick<ValidationResult['ruleViolations'][number], 'nodeId' | 'ruleId' | 'severity' | 'description'>
>;
issues: ReadonlyArray<Pick<Issue, 'kind' | 'severity' | 'message' | 'nodeId' | 'source'> & { ruleId?: string }>;
};

/**
* Attach validation results to the nodes of `graph`, by node id. Results that can't be attached are
* returned in `unattached` for the host to surface: global-scope rules (no node), and nodes that
* aren't in the graph, which includes every node that failed its schema.
*/
/** Attach validation issues to the nodes of `graph`. Issues whose node isn't in the graph are `unattached`. */
export function annotationsFrom(
input: ValidationInput,
graph: SpaceGraph,
): { annotations: AnnotationIndex; unattached: UnattachedAnnotation[] } {
const annotations = new Map<NodeId, NodeAnnotation[]>();
const unattached: UnattachedAnnotation[] = [];

for (const [id, issues] of Object.entries(input.issues)) {
const nodeId = id as NodeId;
for (const [key, { kind, message, severity, line, column }] of Object.entries(issues)) {
const annotation: NodeAnnotation = {
severity,
source: kind,
message,
...(kind === 'rule' && key.startsWith('rule:') && { ruleId: key.slice('rule:'.length) }),
};
if (graph.nodes.has(nodeId)) {
annotations.set(nodeId, [...(annotations.get(nodeId) ?? []), annotation]);
} else {
unattached.push({
...annotation,
nodeId,
...(line !== undefined && { line }),
...(column !== undefined && { column }),
});
}
for (const { kind, severity, message, nodeId, source, ruleId } of input.issues) {
const annotation: NodeAnnotation = {
severity,
source: kind,
message,
...(kind === 'rule' && ruleId !== undefined && { ruleId }),
};
if (nodeId !== undefined && graph.nodes.has(nodeId)) {
annotations.set(nodeId, [...(annotations.get(nodeId) ?? []), annotation]);
} else {
unattached.push({
...annotation,
...(nodeId !== undefined && { nodeId }),
...(source !== undefined && { location: source }),
});
}
}
for (const { nodeId, description, severity, ruleId } of input.ruleViolations ?? []) {
if (nodeId === undefined) unattached.push({ severity, source: 'rule', message: description, ruleId });
}
return { annotations, unattached };
}

Expand Down
5 changes: 2 additions & 3 deletions packages/map-views/src/node/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
type Config,
createSpaceContext,
exportSpaceGraph,
nodeIssues,
validateSpace,
} from 'structured-context/api';
import type { ValidationInput } from '../core/annotations';
Expand All @@ -18,7 +17,7 @@ export type SpaceSnapshot = {
space: string;
schemaId: string | undefined;
graph: ReturnType<typeof exportSpaceGraph>;
/** What `annotationsFrom` attaches: each node's issues and the rule violations */
/** What `annotationsFrom` attaches */
validation: ValidationInput;
definitions: MapViewDefinition[];
};
Expand Down Expand Up @@ -47,7 +46,7 @@ export async function exportSnapshot(
space: spaceName,
schemaId,
graph: exportSpaceGraph(graph.nodes.values(), context.schema.metadata),
validation: { issues: nodeIssues(validation, validateOptions), ruleViolations: validation.ruleViolations },
validation: { issues: validation.issues },
definitions: mapViewsForSchema(loadMapViewDefinitions(config, { configDir }), schemaId),
};
}
Loading
Loading