@canonical/anatomy-dsl 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,175 @@ node:
30
30
 
31
31
  Style values are **design token paths** (`spacing/medium`, `color/surface/button`) — forward-slash delimited references resolved at runtime against the active theme. Primitives like `flow` and `center` are used for layout semantics that don't vary across themes.
32
32
 
33
+ ## Projections
34
+
35
+ Projections bind the anatomy tree directly to graph data, after the Relay
36
+ fragment-colocation pattern: the tree carries its data requirements the way a
37
+ lens component carries its fragment.
38
+
39
+ ```yaml
40
+ ---
41
+ node:
42
+ uri: global.component.entity-card
43
+ projection:
44
+ on: Component # type condition — the tree is a view over one Component
45
+ edges:
46
+ - node:
47
+ uri: global.subcomponent.entity-card-header
48
+ projection:
49
+ field: _meta.title # this node renders the entity's title
50
+ relation:
51
+ cardinality: "1"
52
+ slotName: header
53
+ - node:
54
+ uri: global.component.chip
55
+ projection:
56
+ field: _meta.title # relative to the traversed Tag
57
+ relation:
58
+ cardinality: "0..*"
59
+ slotName: tags
60
+ projection:
61
+ field: documentationStages # traversal populating the slot
62
+ ```
63
+
64
+ Semantics:
65
+
66
+ - The **root node's** `projection.on` establishes the data context — the anatomy
67
+ is a parameterized view over one entity of that GraphQL type, like
68
+ `fragment EntityCard on Component`. Children inherit the context.
69
+ - `projection.field` on a **relation** is a traversal: the slot is populated
70
+ from that field of the current context. The DSL names the field only —
71
+ never `edges.node`. Anatomy cardinality ↔ graph multiplicity is the Relay
72
+ pattern taken one level further.
73
+ - **Cardinality decomposes against the schema.** The upper bound claims
74
+ multiplicity: `..1` maps to an object or scalar field, `..*` to a
75
+ connection or list. The lower bound claims nullability: `0..` tolerates
76
+ `null`, `1..` requires the provider to always have the value. So
77
+ `field: _meta.title` may sit under `cardinality: "1"` (title is total in
78
+ the contract), but a nullable field like `summary` must sit under `0..1`.
79
+ `1..*` asserts a non-empty list — deliberately stronger than GraphQL can
80
+ express, and checkable only at runtime.
81
+ - **Mechanism-blindness.** Whether a plural field is a Relay connection
82
+ (`subcomponents`) or a plain list (`properties`) is a provider mechanism,
83
+ not an anatomy fact. Consumers discover the shape from the SDL and unwrap
84
+ `edges { node }` when needed; an anatomy survives a provider promoting a
85
+ list to a connection unchanged.
86
+ - `projection.on` on a **child node** narrows the traversed entity's type — the
87
+ analog of an inline fragment. Switch cases carrying different `on` values
88
+ mirror an interface resolved through inline fragments.
89
+ - `projection.field` on a **node** means the node renders that field's value,
90
+ as a dot-delimited path relative to the enclosing context (`_meta.title`).
91
+ - A node projection needs at least one of `on` / `field`; a relation projection
92
+ requires `field` and admits no type condition (narrowing belongs on the
93
+ child node).
94
+
95
+ Field names are anchored to the provider schema the docsite runs against
96
+ (compiled with `prefixing: "none"`); the committed SDL is the naming authority.
97
+
98
+ A fully projected anatomy **derives a GraphQL fragment** mechanically — the
99
+ example above reads as:
100
+
101
+ ```graphql
102
+ fragment EntityCardAnatomy on Component {
103
+ _meta { title } # header node
104
+ documentationStages { # tags relation (connection per SDL)
105
+ edges { node { _meta { title } } } # each chip
106
+ }
107
+ }
108
+ ```
109
+
110
+ The derivation rules (type conditions, dot-path expansion, connection
111
+ unwrapping, inline fragments) are specified in the API reference §3.10
112
+ *Derived fragment*; a worked gallery of every projection form is in §13, and
113
+ `examples/yaml/entity-card.anatomy.yaml` is the golden example.
114
+
115
+ ## Props (pinned values)
116
+
117
+ A named node can **pin** props of the component it references — fixing a prop
118
+ value at one tree position:
119
+
120
+ ```yaml
121
+ node:
122
+ uri: global.component.icon
123
+ props:
124
+ icon: chevron-down
125
+ ```
126
+
127
+ This is how icons become idiomatic with zero icon-specific machinery. The
128
+ design system models the icon as a component whose glyph is a required prop
129
+ (`ds:global.component.icon` › `ds:hasProperty [ ds:name "icon" ]`), so icon
130
+ usage in anatomies splits into exactly two cases:
131
+
132
+ - **Consumer-filled icon slot** — an icon-component edge with a slot and no
133
+ pin. The consumer chooses the glyph; the anatomy correctly says nothing.
134
+ - **Component-intrinsic icon** — the accordion chevron, the modal close ×, a
135
+ status glyph: the component's own spec fixes the glyph, and the anatomy
136
+ pins it.
137
+
138
+ Semantics:
139
+
140
+ - Pins live on **named nodes only** — anonymous nodes have no prop surface.
141
+ This is enforced in the types, the parser, the JSON Schema, and SHACL.
142
+ - The DSL **never defines a prop surface** (names, types, optionality live in
143
+ the design system ontology); it only asserts values. Whether a pinned prop
144
+ exists on the component, and whether a value is admissible (e.g. a glyph
145
+ name in the icon set), are consumer-side checks against the DS graph — the
146
+ same posture as projection checking against the provider SDL.
147
+ - Values are scalars, coerced to strings — no token paths or fallback arrays.
148
+ Pins are *values with meaning*, not styles: a theme may reskin what
149
+ `chevron-down` looks like (asset layer), but never remap which glyph an
150
+ anatomy means.
151
+ - A **data-driven** value is a projection (`projection: { field: … }`), and a
152
+ **state-driven** one is a `switch` — pinned props are static by design.
153
+
154
+ In TTL, pins reify like styles do: `hasProp [ a :Prop ; propName "…" ;
155
+ propValue "…" ]`. See `examples/yaml/status-header.anatomy.yaml` for
156
+ intrinsic icons, a status-glyph switch, and an unpinned consumer slot in one
157
+ anatomy.
158
+
159
+ ## Interaction states
160
+
161
+ Interaction states re-value style channels — they never add structure. A
162
+ style key takes an `@state` suffix scoping its value to a state; the unmarked
163
+ key is the default state:
164
+
165
+ ```yaml
166
+ styles:
167
+ interaction.cursor: pointer
168
+ interaction.cursor@disabled: not-allowed
169
+ appearance.background: color/fill/default
170
+ appearance.background@hover: color/fill/default/hover
171
+ appearance.background@disabled: color/fill/default/disabled
172
+ appearance.outline@focus: color/focus-ring # exists only in a state
173
+ ```
174
+
175
+ The state vocabulary is **closed and registry-governed**: `hover`, `active`,
176
+ `focus`, `disabled`, `selected`. Naming follows the industry consensus where
177
+ systems diverge — `active` subsumes Material's *pressed* and Spectrum's
178
+ *down* (and matches Canonical's own token tree); `focus` maps to CSS
179
+ `:focus-visible` (Spectrum's *key-focus*). `@default` is invalid — absence is
180
+ the default. Candidate additions (`checked`, `visited`, `dragged`, `pending`,
181
+ `error`, `read-only`) go through the registry, never by loosening the schema.
182
+
183
+ The boundaries that keep "state machines out of scope" true:
184
+
185
+ - **States hold style values only.** A state that changes the tree is not a
186
+ state — it is a `switch on: internal` case (async-button's
187
+ idle/loading/success/error). The DSL declares appearance *per* state, never
188
+ transitions, triggers, or logic.
189
+ - **Gate vs appearance**: `props: { disabled: true }` (or the consumer) puts
190
+ a node in the disabled state; `…@disabled` styles say how it looks there.
191
+ - The canonical state token is the base token path plus a state leaf segment
192
+ (`color/fill/default/hover`), matching the token tree. A consumer-side lint
193
+ checks that a state-leafed token value agrees with its key's `@state`.
194
+ - The grammar reserves repeatable markers for compound states
195
+ (`@selected@hover`, canonical order: value/control state before user-action
196
+ state); v1 permits a single `@`.
197
+
198
+ In TTL, the Style tuple gains one optional dimension:
199
+ `[ styleKey "appearance.background" ; styleState "hover" ; styleValue "…" ]`.
200
+ See `examples/yaml/stateful-button.anatomy.yaml`.
201
+
33
202
  ## Install
34
203
 
35
204
  ```sh
@@ -111,35 +280,48 @@ All types mirror the [OWL ontology](definitions/ontology.ttl) exactly:
111
280
  | `Node` | `NamedNode \| AnonymousNode` (discriminated on `type`) |
112
281
  | `Edge` | Reified parent→child relationship |
113
282
  | `Relation` | Cardinality and optional slot name |
114
- | `Style` | Reified key-value tuple |
283
+ | `Style` | Reified key-value tuple, with an optional interaction `state` dimension |
115
284
  | `Switch` | Polymorphic position (discriminator: `props \| internal \| override`) |
116
285
  | `SwitchCase` | One alternative within a switch |
286
+ | `Projection` | Fragment-style graph binding on a node (`on` type condition and/or `field` path) |
287
+ | `RelationProjection` | Traversal populating a slot (`field` required) |
288
+ | `Prop` | Pinned prop value on a named node (reified name-value tuple) |
117
289
 
118
290
  ## Repository Structure
119
291
 
120
292
  ```
121
293
  definitions/ Turtle ontology (OWL) + SHACL shapes
122
294
  schemas/ JSON Schema for validating .anatomy.yaml files
123
- docs/ API reference (merged WD404 + WD404.1)
295
+ docs/ API reference (WD404 + WD404.1 + WD404.2 + WD404.3)
124
296
  examples/ Example anatomy files (YAML + Turtle pairs)
125
297
  src/ TypeScript types, parser, and transform
126
298
  ```
127
299
 
128
300
  ## Scope
129
301
 
130
- The Anatomy DSL describes **structure only**. It does not handle:
302
+ The Anatomy DSL describes **structure**, **graph-data bindings** (projections
303
+ — what data each position renders), **pinned prop values** (props — fixed
304
+ component configuration at a position), and **state-scoped styles**
305
+ (interaction states — how channels re-value per state). It does not handle:
131
306
 
132
- - **Prop mapping** — which props a component accepts and how they map to behaviour
133
- - **State or state machines** component states, transitions, or interaction logic
307
+ - **Prop surface definition** — which props a component accepts, their types
308
+ and optionality live in the design system ontology; the DSL only pins values
309
+ - **State machines** — transitions, triggers, and interaction logic; the DSL
310
+ declares appearance per state only, and structural state variation is the
311
+ switch construct's job
134
312
  - **Modifier descriptions** — only design token references are supported, not semantic modifier definitions
135
313
 
136
314
  ## Design Notes
137
315
 
138
316
  Styles are modelled as reified key-value tuples (`hasStyle [ styleKey "…" ; styleValue "…" ]`). This keeps the ontology open-ended while remaining lossless. Frequently used style keys may be promoted to first-class datatype properties in a future version.
139
317
 
318
+ Projections and pinned props reuse the same reification idiom (`hasProjection [ projectionType "…" ; projectionField "…" ]`, `hasProp [ propName "…" ; propValue "…" ]`). Projections attach to both nodes and relations — the reified `Relation` is precisely what makes slot-level traversal annotations possible without changing the `Edge` class; pins attach to named nodes only.
319
+
140
320
  ## Specification Status
141
321
 
142
322
  | Index | Title | Status |
143
323
  |---------|--------------------------|----------------|
144
324
  | [WD404](https://docs.google.com/document/d/1eFr-SNsAZyidnZzpWp1Jeegiat_SSM7mOW_G8p3nXo8/edit?tab=t.pndvuecem8cf) | Anatomy DSL | Approved |
145
325
  | [WD404.1](https://docs.google.com/document/d/1eFr-SNsAZyidnZzpWp1Jeegiat_SSM7mOW_G8p3nXo8/edit?tab=t.pndvuecem8cf) | Anatomy DSL — Addendum 1 | Pending Review |
326
+ | WD404.2 | Anatomy DSL — Projections | Draft (this repository) |
327
+ | WD404.3 | Anatomy DSL — Prop pinning | Draft (this repository) |
@@ -4,13 +4,14 @@
4
4
  @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
5
5
  @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
6
6
  @prefix skos: <http://www.w3.org/2004/02/skos/core#> .
7
+ @prefix graphql: <https://pragma.canonical.com/graphql#> .
7
8
 
8
9
  # ═══════════════════════════════════════════════════════════════
9
10
  # ONTOLOGY DECLARATION
10
11
  # ═══════════════════════════════════════════════════════════════
11
12
 
12
13
  <http://anatomy-dsl.example.org/ontology> a owl:Ontology ;
13
- owl:versionInfo "0.2.0" ;
14
+ owl:versionInfo "0.3.2" ;
14
15
  rdfs:label "Anatomy DSL Ontology" ;
15
16
  rdfs:comment "Compliant with OWL DL profile" ;
16
17
  skos:definition """Meta-model for the Anatomy DSL — a YAML-based language
@@ -19,11 +20,20 @@
19
20
  Models anatomy files as trees of nodes connected by reified edges.
20
21
  Inspired by the Relay GraphQL connection pattern.
21
22
 
22
- Consolidates WD404 (core) and WD404.1 (addendum 1).
23
+ Consolidates WD404 (core), WD404.1 (addendum 1), projections
24
+ (addendum 2): fragment-style bindings of nodes and relations to
25
+ graph data, prop pinning (addendum 3): fixed prop values on
26
+ referenced components, and interaction states (addendum 4):
27
+ state-scoped style values.
23
28
 
24
29
  OUT OF SCOPE (matching the DSL itself):
25
- - Prop mapping
26
- - State or state machines
30
+ - Prop surface definition — names, types, and optionality of a
31
+ component's props live in the design system ontology. Pinned prop
32
+ VALUES are in scope as of addendum 3.
33
+ - State MACHINES — transitions, triggers, and interaction logic.
34
+ Appearance PER interaction state is in scope as of addendum 4
35
+ (styleState); structural state variation remains the Switch
36
+ construct's job.
27
37
  - Modifier descriptions (only token references)
28
38
 
29
39
  Validation shapes defined in shapes.ttl.""" .
@@ -62,6 +72,23 @@ anatomy:Style a owl:Class ;
62
72
  rdfs:label "Style" ;
63
73
  skos:definition "A single style declaration binding a property key to a value." .
64
74
 
75
+ anatomy:Projection a owl:Class ;
76
+ rdfs:label "Projection" ;
77
+ skos:definition """Fragment-style binding to graph data, after the Relay
78
+ colocation pattern. On a node it declares a type condition (like a
79
+ fragment's 'on') and/or the field the node renders; on a relation it
80
+ declares the traversal that populates the slot.""" .
81
+
82
+ anatomy:Prop a owl:Class ;
83
+ rdfs:label "Prop" ;
84
+ skos:definition """A pinned property value: the anatomy fixes one prop of
85
+ the referenced component at this tree position (e.g. the icon component's
86
+ 'icon' prop pinned to 'chevron-down' for an intrinsic chevron). References
87
+ a property defined in the design system ontology — the DSL never defines
88
+ the prop surface itself. Whether the pinned prop exists on the component,
89
+ and whether the value is admissible, is validated by consumers holding
90
+ the design system graph.""" .
91
+
65
92
  # ═══════════════════════════════════════════════════════════════
66
93
  # SWITCH CONSTRUCT
67
94
  # ═══════════════════════════════════════════════════════════════
@@ -126,12 +153,31 @@ anatomy:hasStyle a owl:ObjectProperty ;
126
153
  rdfs:range anatomy:Style ;
127
154
  skos:definition "Attaches a style declaration to a node." .
128
155
 
156
+ anatomy:hasProjection a owl:ObjectProperty ;
157
+ rdfs:label "has projection" ;
158
+ rdfs:domain [ a owl:Class ; owl:unionOf ( anatomy:Node anatomy:Relation ) ] ;
159
+ rdfs:range anatomy:Projection ;
160
+ skos:definition "Attaches a graph-data projection to a node or relation." .
161
+
162
+ anatomy:hasProp a owl:ObjectProperty ;
163
+ rdfs:label "has prop" ;
164
+ rdfs:domain anatomy:NamedNode ;
165
+ rdfs:range anatomy:Prop ;
166
+ skos:definition "Attaches a pinned prop value to a named node. Named nodes only: anonymous nodes have no prop surface to pin." .
167
+
129
168
  # ═══════════════════════════════════════════════════════════════
130
169
  # DATATYPE PROPERTIES
131
170
  # ═══════════════════════════════════════════════════════════════
132
171
 
133
172
  anatomy:uri a owl:DatatypeProperty ;
134
173
  rdfs:label "uri" ;
174
+ # `uri` is the structural primary key of every node in a GraphQL projection
175
+ # of this ontology (one absolute IRI per entity), so an ontology property of
176
+ # the same name collides with it. The projection names this one
177
+ # `anatomyUri`; the RDF term is unchanged and every SPARQL query keeps
178
+ # working. See canonical/pragma-adrs session/B (ruling R-4: a collision is
179
+ # an error, never a silent rename).
180
+ graphql:name "anatomyUri" ;
135
181
  rdfs:domain anatomy:NamedNode ;
136
182
  rdfs:range xsd:string ;
137
183
  skos:definition "Unique identifier for a named node within the design system." ;
@@ -188,3 +234,51 @@ anatomy:styleValue a owl:DatatypeProperty ;
188
234
  "spacing/medium" ,
189
235
  "color/surface/primary" ,
190
236
  "color/surface/button?" .
237
+
238
+ anatomy:styleState a owl:DatatypeProperty ;
239
+ rdfs:label "style state" ;
240
+ rdfs:domain anatomy:Style ;
241
+ rdfs:range xsd:string ;
242
+ skos:definition """Interaction state this style value applies in. Absent
243
+ means the default state. Closed vocabulary (hover, active, focus,
244
+ disabled, selected), governed by registry — 'active' subsumes Material's
245
+ 'pressed' and Spectrum's 'down'; 'focus' maps to CSS :focus-visible
246
+ (Spectrum's 'key-focus'). States re-value style channels only: they never
247
+ add structure (that is the Switch construct) and never define transitions
248
+ or interaction logic (state machines stay out of scope). Authored as an
249
+ @state suffix on the style key.""" ;
250
+ skos:example "hover" , "active" , "focus" , "disabled" , "selected" .
251
+
252
+ anatomy:projectionType a owl:DatatypeProperty ;
253
+ rdfs:label "projection type" ;
254
+ rdfs:domain anatomy:Projection ;
255
+ rdfs:range xsd:string ;
256
+ skos:definition "GraphQL type condition, like a Relay fragment's 'on'. On a root node it establishes the data context for the tree; on a child it narrows the traversed entity's type, like an inline fragment." ;
257
+ skos:example "Component" ,
258
+ "OntologyClass" ,
259
+ "Pattern" .
260
+
261
+ anatomy:propName a owl:DatatypeProperty ;
262
+ rdfs:label "prop name" ;
263
+ rdfs:domain anatomy:Prop ;
264
+ rdfs:range xsd:string ;
265
+ skos:definition "Name of the pinned prop, as defined on the referenced component in the design system ontology." ;
266
+ skos:example "icon" .
267
+
268
+ anatomy:propValue a owl:DatatypeProperty ;
269
+ rdfs:label "prop value" ;
270
+ rdfs:domain anatomy:Prop ;
271
+ rdfs:range xsd:string ;
272
+ skos:definition "The fixed value the anatomy pins the prop to." ;
273
+ skos:example "chevron-down" ,
274
+ "checkmark" ,
275
+ "spinner" .
276
+
277
+ anatomy:projectionField a owl:DatatypeProperty ;
278
+ rdfs:label "projection field" ;
279
+ rdfs:domain anatomy:Projection ;
280
+ rdfs:range xsd:string ;
281
+ skos:definition "Dot-delimited GraphQL field path relative to the enclosing data context. On a node: the field the node renders. On a relation: the traversal populating the slot. Names the field only, never an unwrapping path like edges/node — whether a plural field is a connection or a plain list is a provider mechanism discovered from the schema. The relation's cardinality maps onto the field's shape: upper bound to multiplicity, lower bound to nullability." ;
282
+ skos:example "_meta.title" ,
283
+ "summary" ,
284
+ "documentationStages" .
@@ -5,7 +5,7 @@
5
5
  @prefix skos: <http://www.w3.org/2004/02/skos/core#> .
6
6
 
7
7
  # ═══════════════════════════════════════════════════════════════
8
- # SHACL SHAPES FOR ANATOMY DSL 0.2.0
8
+ # SHACL SHAPES FOR ANATOMY DSL 0.3.2
9
9
  # ═══════════════════════════════════════════════════════════════
10
10
  #
11
11
  # Constraints, patterns, enumerations, and encoding conventions.
@@ -13,13 +13,17 @@
13
13
  #
14
14
  # Shapes:
15
15
  # SpecificationShape — root document
16
- # NamedNodeShape — uri required, encoding pattern
17
- # AnonymousNodeShape — role required
16
+ # NamedNodeShape — uri required, encoding pattern, props allowed
17
+ # AnonymousNodeShape — role required, props forbidden
18
18
  # EdgeShape — edgeTarget XOR edgeSwitch
19
- # RelationShape — cardinality pattern, optional slotName
19
+ # RelationShape — cardinality pattern, optional slotName,
20
+ # optional traversal projection (field required)
20
21
  # SwitchShape — discriminator enum, minimum cases
21
22
  # SwitchCaseShape — case node required
22
- # StyleShape — reified key-value tuple (one key, one value)
23
+ # StyleShape — reified key-value tuple (one key, one value,
24
+ # optional closed-vocabulary interaction state)
25
+ # ProjectionShape — at least one of projectionType/projectionField
26
+ # PropShape — reified name-value tuple (one name, one value)
23
27
  # ═══════════════════════════════════════════════════════════════
24
28
 
25
29
  # ───────────────────────────────────────────────────────────────
@@ -70,6 +74,19 @@ anatomy:NamedNodeShape a sh:NodeShape ;
70
74
  sh:path anatomy:role ;
71
75
  sh:maxCount 0 ;
72
76
  sh:message "NamedNode must not have a role (use AnonymousNode instead)." ;
77
+ ] ;
78
+
79
+ sh:property [
80
+ sh:path anatomy:hasProjection ;
81
+ sh:maxCount 1 ;
82
+ sh:class anatomy:Projection ;
83
+ sh:message "NamedNode admits at most one projection." ;
84
+ ] ;
85
+
86
+ sh:property [
87
+ sh:path anatomy:hasProp ;
88
+ sh:class anatomy:Prop ;
89
+ sh:message "hasProp must point at a Prop." ;
73
90
  ] .
74
91
 
75
92
  # ───────────────────────────────────────────────────────────────
@@ -94,6 +111,19 @@ anatomy:AnonymousNodeShape a sh:NodeShape ;
94
111
  sh:path anatomy:uri ;
95
112
  sh:maxCount 0 ;
96
113
  sh:message "AnonymousNode must not have a uri (use NamedNode instead)." ;
114
+ ] ;
115
+
116
+ sh:property [
117
+ sh:path anatomy:hasProjection ;
118
+ sh:maxCount 1 ;
119
+ sh:class anatomy:Projection ;
120
+ sh:message "AnonymousNode admits at most one projection." ;
121
+ ] ;
122
+
123
+ sh:property [
124
+ sh:path anatomy:hasProp ;
125
+ sh:maxCount 0 ;
126
+ sh:message "Props belong to components: anonymous nodes have no prop surface to pin — use a NamedNode." ;
97
127
  ] .
98
128
 
99
129
  # ───────────────────────────────────────────────────────────────
@@ -161,6 +191,28 @@ anatomy:RelationShape a sh:NodeShape ;
161
191
  sh:path anatomy:slotName ;
162
192
  sh:maxCount 1 ;
163
193
  sh:datatype xsd:string ;
194
+ ] ;
195
+
196
+ # A relation projection is a traversal: the field is mandatory.
197
+ # Type narrowing belongs on the child node, never on the relation.
198
+ sh:property [
199
+ sh:path anatomy:hasProjection ;
200
+ sh:maxCount 1 ;
201
+ sh:class anatomy:Projection ;
202
+ sh:node [
203
+ a sh:NodeShape ;
204
+ sh:property [
205
+ sh:path anatomy:projectionField ;
206
+ sh:minCount 1 ;
207
+ sh:message "Relation projections must name the traversal field." ;
208
+ ] ;
209
+ sh:property [
210
+ sh:path anatomy:projectionType ;
211
+ sh:maxCount 0 ;
212
+ sh:message "Type narrowing lives on the child node, not the relation." ;
213
+ ] ;
214
+ ] ;
215
+ sh:message "Relation admits at most one projection, and it must be a traversal (projectionField, no projectionType)." ;
164
216
  ] .
165
217
 
166
218
  # ───────────────────────────────────────────────────────────────
@@ -229,4 +281,83 @@ anatomy:StyleShape a sh:NodeShape ;
229
281
  sh:maxCount 1 ;
230
282
  sh:datatype xsd:string ;
231
283
  sh:message "Style requires exactly one value." ;
284
+ ] ;
285
+
286
+ # Closed v1 vocabulary, registry-governed. Additions go through the
287
+ # registry (checked, visited, dragged, pending, error, read-only are
288
+ # candidates), never through loosening this list ad hoc. There is no
289
+ # "default" state: an absent styleState IS the default.
290
+ sh:property [
291
+ sh:path anatomy:styleState ;
292
+ sh:maxCount 1 ;
293
+ sh:datatype xsd:string ;
294
+ sh:in ( "hover" "active" "focus" "disabled" "selected" ) ;
295
+ sh:message "styleState must be one of: hover, active, focus, disabled, selected (absent = default state)." ;
296
+ ] .
297
+
298
+ # ───────────────────────────────────────────────────────────────
299
+ # PROJECTION
300
+ # ───────────────────────────────────────────────────────────────
301
+
302
+ anatomy:ProjectionShape a sh:NodeShape ;
303
+ sh:targetClass anatomy:Projection ;
304
+ rdfs:label "Projection Shape" ;
305
+ skos:definition "Fragment-style graph binding: a type condition, a field path, or both — never neither." ;
306
+
307
+ sh:property [
308
+ sh:path anatomy:projectionType ;
309
+ sh:maxCount 1 ;
310
+ sh:datatype xsd:string ;
311
+ sh:pattern "^[A-Z][A-Za-z0-9_]*$" ;
312
+ sh:message "Projection type must be a GraphQL type name (PascalCase)." ;
313
+ ] ;
314
+
315
+ sh:property [
316
+ sh:path anatomy:projectionField ;
317
+ sh:maxCount 1 ;
318
+ sh:datatype xsd:string ;
319
+ sh:pattern "^[_A-Za-z][_A-Za-z0-9]*(\\.[_A-Za-z][_A-Za-z0-9]*)*$" ;
320
+ sh:message "Projection field must be a dot-delimited path of GraphQL field names." ;
321
+ ] ;
322
+
323
+ sh:or (
324
+ [
325
+ sh:property [
326
+ sh:path anatomy:projectionType ;
327
+ sh:minCount 1 ;
328
+ ] ;
329
+ ]
330
+ [
331
+ sh:property [
332
+ sh:path anatomy:projectionField ;
333
+ sh:minCount 1 ;
334
+ ] ;
335
+ ]
336
+ ) ;
337
+ sh:message "Projection requires at least one of projectionType or projectionField." .
338
+
339
+ # ───────────────────────────────────────────────────────────────
340
+ # PROP
341
+ # ───────────────────────────────────────────────────────────────
342
+
343
+ anatomy:PropShape a sh:NodeShape ;
344
+ sh:targetClass anatomy:Prop ;
345
+ rdfs:label "Prop Shape" ;
346
+ skos:definition "Reified pinned-prop tuple: exactly one name and one value. Whether the prop exists on the referenced component is validated by consumers holding the design system graph, not here." ;
347
+
348
+ sh:property [
349
+ sh:path anatomy:propName ;
350
+ sh:minCount 1 ;
351
+ sh:maxCount 1 ;
352
+ sh:datatype xsd:string ;
353
+ sh:pattern "^[a-z][a-zA-Z0-9]*$" ;
354
+ sh:message "Prop name must be a camelCase identifier as defined on the component." ;
355
+ ] ;
356
+
357
+ sh:property [
358
+ sh:path anatomy:propValue ;
359
+ sh:minCount 1 ;
360
+ sh:maxCount 1 ;
361
+ sh:datatype xsd:string ;
362
+ sh:message "Prop requires exactly one value." ;
232
363
  ] .
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@canonical/anatomy-dsl",
3
3
  "description": "Anatomy DSL meta-model: TypeScript types mirroring the OWL ontology, and a YAML-to-Turtle transform",
4
- "version": "0.2.1",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "module": "dist/esm/index.js",
7
7
  "types": "dist/types/index.d.ts",
@@ -29,6 +29,7 @@
29
29
  "@biomejs/biome": "^2.4.6",
30
30
  "@canonical/biome-config": "^0.17.1",
31
31
  "@canonical/typescript-config": "^0.17.1",
32
+ "@types/node": "^26.4.0",
32
33
  "expect-type": "^1.3.0",
33
34
  "typescript": "^5.9.3",
34
35
  "vitest": "^4.0.18",
package/dist/esm/index.js DELETED
@@ -1,2 +0,0 @@
1
- export { parseAnatomyYAML } from "./parse.js";
2
- export { anatomyToTTL } from "./transform.js";
package/dist/esm/parse.js DELETED
@@ -1,72 +0,0 @@
1
- export function parseAnatomyYAML(raw) {
2
- const doc = raw;
3
- return {
4
- root: toNamedNode(doc.node),
5
- };
6
- }
7
- function toNamedNode(raw) {
8
- return {
9
- type: "named",
10
- uri: raw.uri,
11
- ...(raw.styles ? { styles: toStyles(raw.styles) } : {}),
12
- ...(raw.edges ? { edges: raw.edges.map(toEdge) } : {}),
13
- };
14
- }
15
- function toNode(raw) {
16
- if ("uri" in raw && raw.uri) {
17
- return toNamedNode(raw);
18
- }
19
- const anon = raw;
20
- return {
21
- type: "anonymous",
22
- role: anon.role,
23
- ...(anon.styles ? { styles: toStyles(anon.styles) } : {}),
24
- ...(anon.edges ? { edges: anon.edges.map(toEdge) } : {}),
25
- };
26
- }
27
- function toStyles(raw) {
28
- return Object.entries(raw).map(([key, value]) => ({
29
- key,
30
- value: String(value),
31
- }));
32
- }
33
- function toEdge(raw) {
34
- let target;
35
- if (raw.switch) {
36
- target = toSwitch(raw.switch);
37
- }
38
- else if (raw.node) {
39
- target = toNode(raw.node);
40
- }
41
- else if (raw.uri) {
42
- target = { type: "named", uri: raw.uri };
43
- }
44
- else {
45
- throw new Error("Edge must have node, uri, or switch");
46
- }
47
- return {
48
- target,
49
- relation: toRelation(raw.relation),
50
- };
51
- }
52
- function toSwitch(raw) {
53
- return {
54
- discriminator: raw.on,
55
- cases: raw.cases.map(toSwitchCase),
56
- };
57
- }
58
- function toSwitchCase(raw) {
59
- if (raw.uri !== undefined) {
60
- const node = { type: "named", uri: raw.uri };
61
- return { value: raw.uri, node };
62
- }
63
- const node = toNode(raw.node);
64
- const value = node.type === "named" ? node.uri : node.role;
65
- return { value, node };
66
- }
67
- function toRelation(raw) {
68
- return {
69
- cardinality: raw.cardinality,
70
- ...(raw.slotName ? { slotName: raw.slotName } : {}),
71
- };
72
- }
@@ -1,116 +0,0 @@
1
- const PREFIX = "@prefix : <http://anatomy-dsl.example.org/ontology#> .";
2
- const INDENT = " ";
3
- export function anatomyToTTL(spec) {
4
- const lines = [PREFIX, ""];
5
- lines.push("[] a :Specification ;");
6
- lines.push(`${INDENT}:rootNode [`);
7
- writeNode(lines, spec.root, 2);
8
- lines.push(`${INDENT}] .`);
9
- return `${lines.join("\n")}\n`;
10
- }
11
- function writeNode(lines, node, depth) {
12
- if (node.type === "named") {
13
- writeNamedNode(lines, node, depth);
14
- }
15
- else {
16
- writeAnonymousNode(lines, node, depth);
17
- }
18
- const styles = node.styles ?? [];
19
- const edges = node.edges ?? [];
20
- if (styles.length > 0) {
21
- writeStyles(lines, styles, depth, edges.length === 0);
22
- }
23
- if (edges.length > 0) {
24
- writeEdges(lines, edges, depth);
25
- }
26
- }
27
- function writeNamedNode(lines, node, depth) {
28
- const indent = INDENT.repeat(depth);
29
- lines.push(`${indent}a :NamedNode ;`);
30
- const hasMore = (node.styles && node.styles.length > 0) ||
31
- (node.edges && node.edges.length > 0);
32
- lines.push(`${indent}:uri "${node.uri}"${hasMore ? " ;" : ""}`);
33
- }
34
- function writeAnonymousNode(lines, node, depth) {
35
- const indent = INDENT.repeat(depth);
36
- lines.push(`${indent}a :AnonymousNode ;`);
37
- const hasMore = (node.styles && node.styles.length > 0) ||
38
- (node.edges && node.edges.length > 0);
39
- lines.push(`${indent}:role "${node.role}"${hasMore ? " ;" : ""}`);
40
- }
41
- function writeStyles(lines, styles, depth, isLast) {
42
- const indent = INDENT.repeat(depth);
43
- const innerIndent = INDENT.repeat(depth + 1);
44
- lines.push(`${indent}:hasStyle`);
45
- for (const [i, style] of styles.entries()) {
46
- const sep = i < styles.length - 1 ? " ," : isLast ? "" : " ;";
47
- lines.push(`${innerIndent}[ :styleKey "${style.key}" ; :styleValue "${style.value}" ]${sep}`);
48
- }
49
- }
50
- function writeEdges(lines, edges, depth) {
51
- const indent = INDENT.repeat(depth);
52
- for (const [i, edge] of edges.entries()) {
53
- if (i === 0) {
54
- lines.push(`${indent}:hasEdge [`);
55
- }
56
- else {
57
- lines.push(`${indent}] , [`);
58
- }
59
- writeEdge(lines, edge, depth + 1);
60
- }
61
- lines.push(`${indent}]`);
62
- }
63
- function writeEdge(lines, edge, depth) {
64
- const indent = INDENT.repeat(depth);
65
- lines.push(`${indent}a :Edge ;`);
66
- if (isSwitch(edge.target)) {
67
- lines.push(`${indent}:edgeSwitch [`);
68
- writeSwitch(lines, edge.target, depth + 1);
69
- lines.push(`${indent}] ;`);
70
- }
71
- else {
72
- lines.push(`${indent}:edgeTarget [`);
73
- writeNode(lines, edge.target, depth + 1);
74
- lines.push(`${indent}] ;`);
75
- }
76
- writeRelation(lines, edge, depth);
77
- }
78
- function writeSwitch(lines, sw, depth) {
79
- const indent = INDENT.repeat(depth);
80
- lines.push(`${indent}a :Switch ;`);
81
- lines.push(`${indent}:discriminator "${sw.discriminator}" ;`);
82
- for (const [i, sc] of sw.cases.entries()) {
83
- if (i === 0) {
84
- lines.push(`${indent}:hasCase [`);
85
- }
86
- else {
87
- lines.push(`${indent}] , [`);
88
- }
89
- writeSwitchCase(lines, sc, depth + 1);
90
- }
91
- lines.push(`${indent}]`);
92
- }
93
- function writeSwitchCase(lines, sc, depth) {
94
- const indent = INDENT.repeat(depth);
95
- lines.push(`${indent}a :SwitchCase ;`);
96
- lines.push(`${indent}:caseNode [`);
97
- writeNode(lines, sc.node, depth + 1);
98
- lines.push(`${indent}]`);
99
- }
100
- function writeRelation(lines, edge, depth) {
101
- const indent = INDENT.repeat(depth);
102
- lines.push(`${indent}:hasRelation [`);
103
- const inner = INDENT.repeat(depth + 1);
104
- lines.push(`${inner}a :Relation ;`);
105
- if (edge.relation.slotName) {
106
- lines.push(`${inner}:cardinality "${edge.relation.cardinality}" ;`);
107
- lines.push(`${inner}:slotName "${edge.relation.slotName}"`);
108
- }
109
- else {
110
- lines.push(`${inner}:cardinality "${edge.relation.cardinality}"`);
111
- }
112
- lines.push(`${indent}]`);
113
- }
114
- function isSwitch(target) {
115
- return "discriminator" in target;
116
- }
package/dist/esm/types.js DELETED
@@ -1 +0,0 @@
1
- export {};
package/dist/index.d.ts DELETED
@@ -1,3 +0,0 @@
1
- export { parseAnatomyYAML } from "./parse.js";
2
- export { anatomyToTTL } from "./transform.js";
3
- export type { AnonymousNode, Edge, NamedNode, Node, Relation, Specification, Style, Switch, SwitchCase, } from "./types.js";
package/dist/index.js DELETED
@@ -1,2 +0,0 @@
1
- export { parseAnatomyYAML } from "./parse.js";
2
- export { anatomyToTTL } from "./transform.js";
package/dist/parse.d.ts DELETED
@@ -1,2 +0,0 @@
1
- import type { Specification } from "./types.js";
2
- export declare function parseAnatomyYAML(raw: unknown): Specification;
package/dist/parse.js DELETED
@@ -1,72 +0,0 @@
1
- export function parseAnatomyYAML(raw) {
2
- const doc = raw;
3
- return {
4
- root: toNamedNode(doc.node),
5
- };
6
- }
7
- function toNamedNode(raw) {
8
- return {
9
- type: "named",
10
- uri: raw.uri,
11
- ...(raw.styles ? { styles: toStyles(raw.styles) } : {}),
12
- ...(raw.edges ? { edges: raw.edges.map(toEdge) } : {}),
13
- };
14
- }
15
- function toNode(raw) {
16
- if ("uri" in raw && raw.uri) {
17
- return toNamedNode(raw);
18
- }
19
- const anon = raw;
20
- return {
21
- type: "anonymous",
22
- role: anon.role,
23
- ...(anon.styles ? { styles: toStyles(anon.styles) } : {}),
24
- ...(anon.edges ? { edges: anon.edges.map(toEdge) } : {}),
25
- };
26
- }
27
- function toStyles(raw) {
28
- return Object.entries(raw).map(([key, value]) => ({
29
- key,
30
- value: String(value),
31
- }));
32
- }
33
- function toEdge(raw) {
34
- let target;
35
- if (raw.switch) {
36
- target = toSwitch(raw.switch);
37
- }
38
- else if (raw.node) {
39
- target = toNode(raw.node);
40
- }
41
- else if (raw.uri) {
42
- target = { type: "named", uri: raw.uri };
43
- }
44
- else {
45
- throw new Error("Edge must have node, uri, or switch");
46
- }
47
- return {
48
- target,
49
- relation: toRelation(raw.relation),
50
- };
51
- }
52
- function toSwitch(raw) {
53
- return {
54
- discriminator: raw.on,
55
- cases: raw.cases.map(toSwitchCase),
56
- };
57
- }
58
- function toSwitchCase(raw) {
59
- if (raw.uri !== undefined) {
60
- const node = { type: "named", uri: raw.uri };
61
- return { value: raw.uri, node };
62
- }
63
- const node = toNode(raw.node);
64
- const value = node.type === "named" ? node.uri : node.role;
65
- return { value, node };
66
- }
67
- function toRelation(raw) {
68
- return {
69
- cardinality: raw.cardinality,
70
- ...(raw.slotName ? { slotName: raw.slotName } : {}),
71
- };
72
- }
@@ -1,2 +0,0 @@
1
- import type { Specification } from "./types.js";
2
- export declare function anatomyToTTL(spec: Specification): string;
package/dist/transform.js DELETED
@@ -1,114 +0,0 @@
1
- const PREFIX = "@prefix : <http://anatomy-dsl.example.org/ontology#> .";
2
- const INDENT = " ";
3
- export function anatomyToTTL(spec) {
4
- const lines = [PREFIX, ""];
5
- lines.push("[] a :Specification ;");
6
- lines.push(`${INDENT}:rootNode [`);
7
- writeNode(lines, spec.root, 2);
8
- lines.push(`${INDENT}] .`);
9
- return `${lines.join("\n")}\n`;
10
- }
11
- function writeNode(lines, node, depth) {
12
- if (node.type === "named") {
13
- writeNamedNode(lines, node, depth);
14
- }
15
- else {
16
- writeAnonymousNode(lines, node, depth);
17
- }
18
- const styles = node.styles ?? [];
19
- const edges = node.edges ?? [];
20
- if (styles.length > 0) {
21
- writeStyles(lines, styles, depth, edges.length === 0);
22
- }
23
- if (edges.length > 0) {
24
- writeEdges(lines, edges, depth);
25
- }
26
- }
27
- function writeNamedNode(lines, node, depth) {
28
- const indent = INDENT.repeat(depth);
29
- lines.push(`${indent}a :NamedNode ;`);
30
- const hasMore = (node.styles && node.styles.length > 0) || (node.edges && node.edges.length > 0);
31
- lines.push(`${indent}:uri "${node.uri}"${hasMore ? " ;" : ""}`);
32
- }
33
- function writeAnonymousNode(lines, node, depth) {
34
- const indent = INDENT.repeat(depth);
35
- lines.push(`${indent}a :AnonymousNode ;`);
36
- const hasMore = (node.styles && node.styles.length > 0) || (node.edges && node.edges.length > 0);
37
- lines.push(`${indent}:role "${node.role}"${hasMore ? " ;" : ""}`);
38
- }
39
- function writeStyles(lines, styles, depth, isLast) {
40
- const indent = INDENT.repeat(depth);
41
- const innerIndent = INDENT.repeat(depth + 1);
42
- lines.push(`${indent}:hasStyle`);
43
- for (const [i, style] of styles.entries()) {
44
- const sep = i < styles.length - 1 ? " ," : isLast ? "" : " ;";
45
- lines.push(`${innerIndent}[ :styleKey "${style.key}" ; :styleValue "${style.value}" ]${sep}`);
46
- }
47
- }
48
- function writeEdges(lines, edges, depth) {
49
- const indent = INDENT.repeat(depth);
50
- for (const [i, edge] of edges.entries()) {
51
- if (i === 0) {
52
- lines.push(`${indent}:hasEdge [`);
53
- }
54
- else {
55
- lines.push(`${indent}] , [`);
56
- }
57
- writeEdge(lines, edge, depth + 1);
58
- }
59
- lines.push(`${indent}]`);
60
- }
61
- function writeEdge(lines, edge, depth) {
62
- const indent = INDENT.repeat(depth);
63
- lines.push(`${indent}a :Edge ;`);
64
- if (isSwitch(edge.target)) {
65
- lines.push(`${indent}:edgeSwitch [`);
66
- writeSwitch(lines, edge.target, depth + 1);
67
- lines.push(`${indent}] ;`);
68
- }
69
- else {
70
- lines.push(`${indent}:edgeTarget [`);
71
- writeNode(lines, edge.target, depth + 1);
72
- lines.push(`${indent}] ;`);
73
- }
74
- writeRelation(lines, edge, depth);
75
- }
76
- function writeSwitch(lines, sw, depth) {
77
- const indent = INDENT.repeat(depth);
78
- lines.push(`${indent}a :Switch ;`);
79
- lines.push(`${indent}:discriminator "${sw.discriminator}" ;`);
80
- for (const [i, sc] of sw.cases.entries()) {
81
- if (i === 0) {
82
- lines.push(`${indent}:hasCase [`);
83
- }
84
- else {
85
- lines.push(`${indent}] , [`);
86
- }
87
- writeSwitchCase(lines, sc, depth + 1);
88
- }
89
- lines.push(`${indent}]`);
90
- }
91
- function writeSwitchCase(lines, sc, depth) {
92
- const indent = INDENT.repeat(depth);
93
- lines.push(`${indent}a :SwitchCase ;`);
94
- lines.push(`${indent}:caseNode [`);
95
- writeNode(lines, sc.node, depth + 1);
96
- lines.push(`${indent}]`);
97
- }
98
- function writeRelation(lines, edge, depth) {
99
- const indent = INDENT.repeat(depth);
100
- lines.push(`${indent}:hasRelation [`);
101
- const inner = INDENT.repeat(depth + 1);
102
- lines.push(`${inner}a :Relation ;`);
103
- if (edge.relation.slotName) {
104
- lines.push(`${inner}:cardinality "${edge.relation.cardinality}" ;`);
105
- lines.push(`${inner}:slotName "${edge.relation.slotName}"`);
106
- }
107
- else {
108
- lines.push(`${inner}:cardinality "${edge.relation.cardinality}"`);
109
- }
110
- lines.push(`${indent}]`);
111
- }
112
- function isSwitch(target) {
113
- return "discriminator" in target;
114
- }
@@ -1,3 +0,0 @@
1
- export { parseAnatomyYAML } from "./parse.js";
2
- export { anatomyToTTL } from "./transform.js";
3
- export type { AnonymousNode, Edge, NamedNode, Node, Relation, Specification, Style, Switch, SwitchCase, } from "./types.js";
@@ -1,2 +0,0 @@
1
- import type { Specification } from "./types.js";
2
- export declare function parseAnatomyYAML(raw: unknown): Specification;
@@ -1,2 +0,0 @@
1
- import type { Specification } from "./types.js";
2
- export declare function anatomyToTTL(spec: Specification): string;
@@ -1,36 +0,0 @@
1
- export interface Specification {
2
- root: NamedNode;
3
- }
4
- export interface NamedNode {
5
- type: "named";
6
- uri: string;
7
- styles?: Style[];
8
- edges?: Edge[];
9
- }
10
- export interface AnonymousNode {
11
- type: "anonymous";
12
- role: string;
13
- styles?: Style[];
14
- edges?: Edge[];
15
- }
16
- export type Node = NamedNode | AnonymousNode;
17
- export interface Edge {
18
- target: Node | Switch;
19
- relation: Relation;
20
- }
21
- export interface Relation {
22
- cardinality: string;
23
- slotName?: string;
24
- }
25
- export interface Style {
26
- key: string;
27
- value: string;
28
- }
29
- export interface Switch {
30
- discriminator: "props" | "internal" | "override";
31
- cases: SwitchCase[];
32
- }
33
- export interface SwitchCase {
34
- value: string;
35
- node: Node;
36
- }
package/dist/types.d.ts DELETED
@@ -1,36 +0,0 @@
1
- export interface Specification {
2
- root: NamedNode;
3
- }
4
- export interface NamedNode {
5
- type: "named";
6
- uri: string;
7
- styles?: Style[];
8
- edges?: Edge[];
9
- }
10
- export interface AnonymousNode {
11
- type: "anonymous";
12
- role: string;
13
- styles?: Style[];
14
- edges?: Edge[];
15
- }
16
- export type Node = NamedNode | AnonymousNode;
17
- export interface Edge {
18
- target: Node | Switch;
19
- relation: Relation;
20
- }
21
- export interface Relation {
22
- cardinality: string;
23
- slotName?: string;
24
- }
25
- export interface Style {
26
- key: string;
27
- value: string;
28
- }
29
- export interface Switch {
30
- discriminator: "props" | "internal" | "override";
31
- cases: SwitchCase[];
32
- }
33
- export interface SwitchCase {
34
- value: string;
35
- node: Node;
36
- }
package/dist/types.js DELETED
@@ -1 +0,0 @@
1
- export {};