Alloy Object Model + Renderer Proposal
"Spicy String Concatenation" β "Spicy String Generation" πΆοΈ
1. Goals
Enable Alloy to support three categories of output, each with the appropriate collection pattern:
| Category |
Examples |
Collection |
Model |
| Source code |
Python, TypeScript, C# |
Evaluator-driven |
RenderedTextTree β Prettier |
| Data/config formats |
JSON, YAML, TOML, XML |
Evaluator-driven |
DataModelTree β format renderer |
| Schema/definition languages |
GraphQL, SQL, Protobuf, HCL |
Component-driven |
DefinitionState β domain renderer |
Preserve Alloy's existing strengths: JSX ergonomics, reactive recomputation, multi-file outputs, and deterministic formatting.
2. Core Design: Evaluator + Collector
The Problem
Today, renderTree serves two roles simultaneously:
- Component evaluator: executes
ComponentCreator thunks, runs reactive effects, manages contexts, tracks diagnostics and devtools state. This is generic.
- Text tree builder: constructs a
RenderedTextTree by appending strings and Prettier-backed print hooks into a nested array structure. This is text-specific.
These two roles are interleaved. Every call to appendChild both resolves a child (generic) and places the result into a RenderedTextTree node (text-specific). Intrinsic handling (<indent>, <group>, etc.) maps directly to Prettier builders inside the same function.
The @alloy-js/graphql package already works around this: renderSchema calls renderTree for its component-evaluation side effect (populating SchemaState via context), then discards the returned RenderedTextTree entirely. This confirms that component evaluation is separable from model construction β but the current code doesn't make that separation explicit.
The Solution
Factor renderTree into a generic component evaluator that delegates model construction to a pluggable model collector.
renderTree(children, collector?) -> collector.result
The component evaluator owns:
- Reactive scope setup (
root, effect)
ComponentCreator thunk execution
- Child normalization (refs, refkeys, renderables, arrays, primitives)
- Context management and propagation
- Reactive re-rendering (replacing subtrees when dependencies change)
- Diagnostics, devtools, error handling, render stack
The model collector owns:
- Creating model nodes
- Handling primitives (strings, numbers) β what they mean in this model
- Assembling child nodes into parent nodes
- Domain-specific concerns (e.g., text collector tracks content for conditional formatting; data model collector validates structure)
This is analogous to React's host config (see Appendix E): react-dom and react-native provide different host configs to the same reconciler. Here, @alloy-js/source and @alloy-js/data-model provide different model collectors to the same component evaluator.
Intrinsic elements (e.g., <indent>, <group>, <line>) are domain-specific, not core primitives. Each domain package defines its own intrinsics β @alloy-js/source defines formatting intrinsics that map to Prettier builders; @alloy-js/data-model may define its own or none at all. Because intrinsics are scoped to their domain package, they simply don't exist in other domains; no cross-domain rejection logic is needed (see Appendix F).
Two Collection Patterns
Model construction happens via one of two patterns:
Evaluator-driven collection β the evaluator invokes collector callbacks as it traverses components, producing a model tree. Used when output structure mirrors component tree structure.
- Source code: The text collector creates
RenderedTextTree array nodes, appends strings as text fragments, maps intrinsic elements to Prettier-backed print hooks, and tracks content (isEmpty, childrenWithContent) for conditional formatting. This is a direct extraction of current renderTree behavior.
- Data/config formats: The data model collector creates
DataModelTree nodes. Source-code formatting intrinsics like <indent> are scoped to @alloy-js/source and are not available in data model subtrees.
Component-driven collection β components push into shared state via context as they execute. The evaluator just runs components; no collector is needed. Used when output is a graph or flat namespace with cross-references.
- GraphQL: Schema components (
ObjectType, Field, etc.) register into SchemaState. A GraphQL schema is a graph: types reference each other freely (User has a field of type [Post], Post has a field of type User), and the final schema is a flat namespace of named types, not a nested tree. Component nesting in JSX is for authoring convenience, not output structure.
- SQL: Table, constraint, and index components register into
SqlSchemaState. SQL schemas form a graph of foreign key relationships that can be forward references or circular (orders references users, users references default_order). Tables must be emitted in dependency order or use ALTER TABLE for deferred constraints β this ordering is a serialization concern, not a component-tree concern.
- Protobuf: Message and enum components register into
ProtoState. A .proto file defines a flat namespace of top-level type definitions where messages reference each other by name. Field numbers must be tracked per-message, and nested message types create a naming scope but not a structural dependency β the output is a flat list of definitions, not a tree that mirrors how components are nested.
For guidance on which pattern to use, see Appendix H. For detailed pipeline diagrams per domain, see Appendix C.
Conceptual Pipeline
Component tree (JSX)
β model (tree or graph, domain-specific)
β document tree (renderer-specific serialization structure)
β string output (or file contents)
See the glossary for definitions of each stage.
Renderers are serializers: they do not alter the data. They may make representation decisions (key ordering, layout style, syntax choice) within the target format's constraints. Format-specific rendering hints, provided via context, can guide these decisions.
3. Package Architecture
Package Roles
An Alloy package can take on one or more of the following roles:
- Runtime: JSX runtime, render scheduling,
Output/SourceDirectory, and shared types. (@alloy-js/core)
- Model producer (component library): JSX components that build a model tree or register into shared state. (
@alloy-js/typescript, @alloy-js/python, @alloy-js/graphql)
- Renderer: converts a model into a document tree and then into output text. (
@alloy-js/json, @alloy-js/yaml)
- Hybrid: combines model production and rendering for a domain with a single output format. (
@alloy-js/graphql is a hybrid β it provides both schema components and SDL/introspection renderers.)
A package includes only the directories that apply to its roles (see Suggested Directory Layout).
Core (@alloy-js/core)
The runtime package. All domain-specific code moves out; core has no dependency on Prettier. See Appendix B for the current structure this refactors from.
Provides:
- Generic component evaluator (the factored
renderTree)
ModelCollector interface (for evaluator-driven domains)
- JSX runtime, reactivity (effects, refs, computed), context, scheduling
- File system primitives:
Output, SourceDirectory
- Top-level
render() and renderAsync() functions (see Core's Role in Output Assembly)
- Diagnostics, devtools, refkey system
Domain Packages
@alloy-js/source (evaluator-driven) β the source-code model, extracted from today's core:
SourceFile component
- Text collector implementation
RenderedTextTree types, print hooks, formatting intrinsics (<indent>, <group>, etc.)
- Content tracking (
isEmpty, createContentSlot, conditional formatting utilities)
printTree function (Prettier integration)
@alloy-js/data-model (evaluator-driven):
DataFile component
- Data model collector implementation
DataModelTree types
- Generic data components (
<Object>, <Array>, <Scalar>, <Property>) β see Appendix D for cross-renderer guarantees
@alloy-js/definitions (component-driven):
DefinitionScope component (multi-file coordination)
DefinitionFile component
DefinitionRegistry<T> (generic nameβdefinition map with deduplication)
DefinitionState interface (contract for domain state implementations)
Format and Language Packages
Language packages (@alloy-js/typescript, @alloy-js/python, @alloy-js/csharp, etc.) remain component libraries. They now depend on @alloy-js/source (instead of core) for formatting primitives.
Format packages (@alloy-js/json, @alloy-js/yaml, @alloy-js/toml, @alloy-js/xml) are renderer packages that convert DataModelTree to their document tree and then to string output. Optional format-specific components (e.g., Yaml.LiteralStyle) live alongside the renderer.
Domain-specific packages (@alloy-js/graphql, @alloy-js/sql, @alloy-js/protobuf) provide domain state implementing DefinitionState, definition components that register into that state, and domain-specific renderers (SDL, DDL, .proto syntax). They use DefinitionFile and DefinitionScope from @alloy-js/definitions.
Suggested Directory Layout
Packages include only the directories that apply:
src/components/ β JSX surface area (component creators)
src/model/ β model tree types, builders, normalization
src/collector/ β model collector implementation
src/renderer/ β model β document β string pipeline
src/document/ β document tree types and helpers
src/context/, src/builtins/, src/symbols/ β as used today
4. File Components and Serialization
Each output category has its own file component that establishes the rendering domain for its subtree:
DefinitionFile must be wrapped in a DefinitionScope for multi-file coordination.
Lazy Serialization Convention
Each file component stores a serializer callback in context.meta.file:
meta.file = {
path: "main.py",
serialize: (options) => /* domain-specific serialization */
}
Serialization happens once after all reactive updates settle, not on every component update. Each file component provides its own serializer without core depending on any domain package.
Example β SourceFile:
export function SourceFile(props: SourceFileProps) {
const subtree: RenderedTextTree = [];
const nodeContext = getContext()!;
// Children render into subtree using text collector...
nodeContext.meta.file = {
path: props.path,
serialize: (options) => printTree(subtree, options),
};
return props.children;
}
Core's Role in Output Assembly
The top-level render() function in core:
- Evaluates the component tree (all reactive updates settle)
- Walks the rendered tree looking for
context.meta.file and context.meta.directory
- Calls
meta.file.serialize(options) for each file to produce output
- Assembles the
OutputDirectory structure
Core is completely generic β it knows nothing about text vs. data vs. definitions.
Mixed-Domain Output
A single Output can contain files with different model types and collection strategies in the same render pass:
<Output>
<SourceFile path="main.py">{/* Python code */}</SourceFile>
<DataFile path="config.json" format="json">
<Object>
<Property name="version">
<Scalar>1.0</Scalar>
</Property>
</Object>
</DataFile>
<DefinitionScope domain="graphql">
<DefinitionFile path="schema.graphql">
<ObjectType name="User" />
</DefinitionFile>
</DefinitionScope>
</Output>
5. Format-Specific Components
Format packages (@alloy-js/yaml, @alloy-js/xml, @alloy-js/toml) export components that carry format-specific information. These fall into two categories.
Rendering Hints
Rendering hints control how data is represented in a particular format without changing what the data is. They are context providers wrapping generic content. The matching renderer reads the context; non-matching renderers ignore it and process children as-is. Data is never lost.
Examples:
-
YAML string style: Yaml.LiteralStyle wraps a Scalar. The YAML renderer uses | block style. Other renderers output the string normally.
<Yaml.LiteralStyle>
<Scalar>hello\nworld</Scalar>
</Yaml.LiteralStyle>
-
YAML block vs. flow: Yaml.FlowStyle wraps an Object or Array. The YAML renderer uses inline {}/[] syntax. Other renderers output the structure normally.
-
TOML inline table: Toml.InlineTable wraps an Object. The TOML renderer uses {key = value} syntax instead of a [table] header. Other renderers output the object normally.
-
XML attributes: Xml.AsAttribute wraps Property nodes. The XML renderer emits them as element attributes. Other renderers output the properties normally.
<Object>
<Xml.AsAttribute>
<Property name="id">
<Scalar>42</Scalar>
</Property>
</Xml.AsAttribute>
<Property name="body">
<Object>...</Object>
</Property>
</Object>
The same component tree produces format-appropriate output from each renderer. The XML renderer honors the Xml.AsAttribute hint; the JSON renderer ignores it and emits the property normally. The data is identical β only the representation changes:
XML:
<root id="42"><body>...</body></root>
JSON:
{"id": 42, "body": {...}}
-
XML element naming: Xml.ItemElement wraps an Array to specify the element name for each item.
-
XML namespaces: Xml.Namespace wraps elements to bind a namespace prefix.
Multiple format hints can wrap the same content without conflict β each renderer reads only the contexts it understands. See Appendix F for how mismatched components are handled.
Format Metadata
Format metadata is information about the format itself: XML processing instructions (<?xml ...?>), YAML document markers (---), TOML comments, DTD declarations. These have no generic equivalent. When a non-matching renderer encounters them, it produces no output for that subtree β this is expected behavior.
Shared References (Refkeys)
YAML anchors/aliases, JSON $ref pointers, and similar cross-reference mechanisms are serialization-specific representations of a generic concept: shared references. Alloy already provides this via refkeys.
Rather than requiring format-specific anchor/alias components, a format package provides a rendering context that controls how refkeys are serialized. The data model itself uses refkeys; the format decides how to represent them.
The Pattern
Format-specific components are context providers following two rules:
- Rendering hints wrap generic content. Matching renderer reads context; others ignore it. Data is always preserved.
- Format metadata may have no generic content. Non-matching renderers produce no output. This is intentional.
6. Open Decisions / Questions
- What is the shape of the
ModelCollector interface? The text collector and data model collector have very different needs (text appends strings; data model builds structure from components). The interface must be narrow enough to be generic but expressive enough that collectors aren't fighting it.
- How does the collector interact with reactive re-rendering? When a dependency changes and a subtree is re-evaluated, the collector needs to replace (not append) the affected nodes. The current code handles this by index replacement on the
RenderedTextTree array β the collector interface needs an equivalent mechanism.
- How should domain-scoped intrinsics integrate with the JSX type system across packages?
- Should file components infer domain/format from file extension, require it explicitly, or support both with sensible defaults?
- What methods should
DefinitionState require? At minimum: register(), resolve(), serialize(), but are there other common operations?
- How does multi-file coordination work with
DefinitionScope? Does it automatically merge states from multiple files, or do domain packages implement merging logic?
- How do devtools visualize component-driven collection where there's no intermediate tree to inspect? Can we expose the
DefinitionRegistry state for inspection?
- What is the testing story? The current test infrastructure is text-oriented. Data model and component-driven testing need different assertion utilities.
- How do refkeys work in the data model? Currently refkeys are converted to source-file references via
SourceFileContext.reference. In a data model, what does a refkey produce? (JSON $ref? Repeated values?)
- What are the backwards compatibility implications? Moving text-specific code from core to
@alloy-js/source is a breaking change for existing users.
Appendices
A. Glossary
- Component tree: the tree produced by JSX (
Children, ComponentCreators, primitives). The input to the conceptual pipeline.
- Model: a domain-specific intermediate representation (e.g.,
DataModelTree, RenderedTextTree, GraphQLSchema). May be a tree or a graph/flat collection. Some models support multiple renderers; others are coupled to a single renderer. Built by a model collector or by component-driven collection.
- Document tree: a renderer-specific serialization structure (e.g.,
Prettier.Doc, YAML node tree, GraphQL DocumentNode). The intermediate step between model and output text.
- Output text: the final string/file contents. Produced by the lazy serialization convention.
- Evaluator-driven collection: the evaluator encounters component results and delegates to a
ModelCollector that assembles them into a tree structure. Used when output structure mirrors component tree structure. See Two Collection Patterns and Appendix H.
- Component-driven collection: components push into shared state via context as they execute. The evaluator just runs components; no collector needed. Used when output is a graph or flat namespace. See Two Collection Patterns and Appendix H.
B. Current Alloy Structure
These are the parts of the current codebase that the core design refactors:
renderTree builds a RenderedTextTree from Children, printTree produces a Prettier.Doc, and render converts that into OutputDirectory/files. See packages/core/src/render.ts.
- JSX runtime returns a
ComponentCreator thunk, not a ReactElement-like object. The renderer invokes the thunk during traversal. See packages/core/src/jsx-runtime.ts and packages/core/src/runtime/component.ts.
- Formatting is handled by intrinsics and print hooks, which are text-specific. See
packages/core/src/runtime/intrinsic.ts and packages/core/src/print-hook.ts.
- File structure is defined by
Output and SourceFile, which attach metadata into the render context. See packages/core/src/components/Output.tsx and packages/core/src/components/SourceFile.tsx.
- Reactivity is driven by
@vue/reactivity and updates the rendered tree when dependencies change. See packages/core/src/render.ts.
C. Detailed Pipeline Examples
See Conceptual Pipeline for the generic form.
Component tree β RenderedTextTree (via text collector) β Prettier.Doc β string
Component tree β DataModelTree (via data model collector) β [format] document tree β string
Where [format] is one of: JSON (value/object/array nodes), YAML (scalar/sequence/mapping), TOML (tables, arrays-of-tables, key paths), or XML (elements/attributes).
Component tree (components register into shared state) β domain model β domain document tree β string
For GraphQL: SchemaState β DocumentNode (SDL) or introspection JSON. For SQL: SqlSchemaState β DDL statements. For Protobuf: ProtoState β .proto syntax.
D. Data Model Component Guarantees
The generic data components (<Object>, <Array>, <Scalar>, <Property> from @alloy-js/data-model) explicitly document which semantic properties they preserve across renderers. Guarantees are based on the lowest common denominator of renderer support: if a component guarantees a property, every format renderer must honor it. If not, renderers are free to handle it as the target format requires.
The set of guarantees is driven by what all supported data formats can uphold. A guarantee that any renderer would need to violate should not be made.
Example β ordering. An <Array> guarantees item order is preserved: every target format supports ordered sequences, so renderers must respect it. An <Object> does not guarantee key order: JSON specifies that key order is not significant, and TOML requires key reordering to produce valid output (bare key-value pairs before sub-tables). Because not all renderers can preserve key order, the generic <Object> makes no such promise. A user who needs ordered keys reaches for a format-specific component.
E. Comparison to React/Vue Architecture
Where it aligns:
- React splits "describe the tree" from "render to a host" with pluggable host configs (
react-dom, react-native, react-test-renderer). The ModelCollector plays the same role: different collectors tell the same generic evaluator how to construct different model trees.
- Vue/vuerx: Alloy already follows the "evaluate JSX to values or thunks using reactive effects" style, making
DataModelTree building feel natural.
Where it differs:
- React builds a
ReactElement tree then reconciles to a UI host. Alloy does not build a ReactElement equivalent; JSX yields a ComponentCreator thunk and renderTree executes it.
- React's host config is a rich interface (~30 methods) designed for long-lived UI with mutation, insertion, and removal. Alloy's
ModelCollector is simpler because it builds static artifacts, not a live instance graph. There is no commit phase or reconciliation.
- Vue's runtime is UI-oriented; Alloy uses Vue reactivity for build-time recomputation, not UI updates.
F. Renderer Mixing and Error Behavior
- Components rely on context assumptions rather than negotiated capabilities (existing behavior).
- Mixing incompatible components can throw at runtime (e.g., TS components requiring a TS lexical scope) or render syntactically invalid content (e.g., a Python import inside a TS file).
- Format-specific rendering hints mitigate this for the data model: they wrap generic content, so non-matching renderers ignore the hint and still process children. Risk is limited to format metadata components (no generic content).
- Because intrinsic elements are domain-scoped, cross-domain intrinsic misuse is largely prevented by the type system rather than runtime checks.
- The existing "silently ignore, don't fail explicitly" philosophy is retained. No capability negotiation system is needed.
G. Additional Use Cases
Because renderers preserve the model's data (they may choose representation but do not alter semantic content):
- Multi-format config output from one data model (e.g., YAML for humans, JSON for machines)
- Canonicalization pipelines: normalize key ordering or value forms once, then render to multiple formats
- Schema-based validation: validate the model tree against a JSON Schema or custom rules before rendering
- Testing snapshots: serialize the model tree to a stable debug format for snapshot tests
- Devtools visualization: inspect the model tree directly to diagnose output issues
H. Choosing a Collection Pattern
See Two Collection Patterns for full definitions. This appendix provides a quick decision guide.
Use evaluator-driven collection when:
- Output structure mirrors component tree structure
- Positional relationships matter (text, nested data)
- Children belong to their parents in a tree hierarchy
- Reactive subtree replacement is important
Use component-driven collection when:
- Output is a graph with cross-references
- Components register into a flat namespace or shared registry
- Definition order doesn't matter (or is normalized post-collection)
- Components need to reference each other non-hierarchically
I. Migration Impact
This appendix summarizes what changes for each existing package.
@alloy-js/core β becomes a pure runtime package. Text-specific code is extracted: RenderedTextTree types, print hooks, formatting intrinsics (<indent>, <group>, etc.), content tracking (isEmpty, childrenWithContent, createContentSlot), printTree, and SourceFile all move to @alloy-js/source. The Prettier dependency is removed from core. Everything that remains β JSX runtime, reactivity, context, Output, SourceDirectory, diagnostics, devtools, refkeys β is already generic. This is a breaking change: code that imports text-specific symbols from @alloy-js/core must update imports to @alloy-js/source.
@alloy-js/source (new) β receives all text-specific code extracted from core. This is not new functionality; it is a direct extraction of the current renderTree/printTree pipeline into its own package. Existing behavior is preserved.
Language packages (@alloy-js/typescript, @alloy-js/python, @alloy-js/csharp, etc.) β remain component libraries. Their dependency on @alloy-js/core for formatting primitives (SourceFile, <indent>, content tracking) changes to a dependency on @alloy-js/source. No component logic changes; only import paths shift.
@alloy-js/graphql β currently works around the coupled renderTree by calling it for its side effect and discarding the RenderedTextTree. After the refactor, renderTree accepts no collector for component-driven domains, making the workaround unnecessary. @alloy-js/graphql can adopt DefinitionFile and DefinitionScope from @alloy-js/definitions for multi-file coordination, and implement DefinitionState for its SchemaState. Existing renderSchema behavior is preserved.
@alloy-js/data-model (new) β new package providing DataFile, DataModelTree, and generic data components (<Object>, <Array>, <Scalar>, <Property>). No migration needed; this is additive.
@alloy-js/definitions (new) β new package providing DefinitionScope, DefinitionFile, DefinitionRegistry<T>, and DefinitionState. No migration needed; this is additive. Existing component-driven packages like @alloy-js/graphql can adopt it incrementally.
Format packages (@alloy-js/json, @alloy-js/yaml, @alloy-js/toml, @alloy-js/xml) β new renderer packages. No migration needed; these are additive.
Alloy Object Model + Renderer Proposal
"Spicy String Concatenation" β "Spicy String Generation" πΆοΈ
1. Goals
Enable Alloy to support three categories of output, each with the appropriate collection pattern:
RenderedTextTreeβ PrettierDataModelTreeβ format rendererDefinitionStateβ domain rendererPreserve Alloy's existing strengths: JSX ergonomics, reactive recomputation, multi-file outputs, and deterministic formatting.
2. Core Design: Evaluator + Collector
The Problem
Today,
renderTreeserves two roles simultaneously:ComponentCreatorthunks, runs reactive effects, manages contexts, tracks diagnostics and devtools state. This is generic.RenderedTextTreeby appending strings and Prettier-backed print hooks into a nested array structure. This is text-specific.These two roles are interleaved. Every call to
appendChildboth resolves a child (generic) and places the result into aRenderedTextTreenode (text-specific). Intrinsic handling (<indent>,<group>, etc.) maps directly to Prettier builders inside the same function.The
@alloy-js/graphqlpackage already works around this:renderSchemacallsrenderTreefor its component-evaluation side effect (populatingSchemaStatevia context), then discards the returnedRenderedTextTreeentirely. This confirms that component evaluation is separable from model construction β but the current code doesn't make that separation explicit.The Solution
Factor
renderTreeinto a generic component evaluator that delegates model construction to a pluggable model collector.The component evaluator owns:
root,effect)ComponentCreatorthunk executionThe model collector owns:
This is analogous to React's host config (see Appendix E):
react-domandreact-nativeprovide different host configs to the same reconciler. Here,@alloy-js/sourceand@alloy-js/data-modelprovide different model collectors to the same component evaluator.Intrinsic elements (e.g.,
<indent>,<group>,<line>) are domain-specific, not core primitives. Each domain package defines its own intrinsics β@alloy-js/sourcedefines formatting intrinsics that map to Prettier builders;@alloy-js/data-modelmay define its own or none at all. Because intrinsics are scoped to their domain package, they simply don't exist in other domains; no cross-domain rejection logic is needed (see Appendix F).Two Collection Patterns
Model construction happens via one of two patterns:
Evaluator-driven collection β the evaluator invokes collector callbacks as it traverses components, producing a model tree. Used when output structure mirrors component tree structure.
RenderedTextTreearray nodes, appends strings as text fragments, maps intrinsic elements to Prettier-backed print hooks, and tracks content (isEmpty,childrenWithContent) for conditional formatting. This is a direct extraction of currentrenderTreebehavior.DataModelTreenodes. Source-code formatting intrinsics like<indent>are scoped to@alloy-js/sourceand are not available in data model subtrees.Component-driven collection β components push into shared state via context as they execute. The evaluator just runs components; no collector is needed. Used when output is a graph or flat namespace with cross-references.
ObjectType,Field, etc.) register intoSchemaState. A GraphQL schema is a graph: types reference each other freely (Userhas a field of type[Post],Posthas a field of typeUser), and the final schema is a flat namespace of named types, not a nested tree. Component nesting in JSX is for authoring convenience, not output structure.SqlSchemaState. SQL schemas form a graph of foreign key relationships that can be forward references or circular (ordersreferencesusers,usersreferencesdefault_order). Tables must be emitted in dependency order or useALTER TABLEfor deferred constraints β this ordering is a serialization concern, not a component-tree concern.ProtoState. A.protofile defines a flat namespace of top-level type definitions where messages reference each other by name. Field numbers must be tracked per-message, and nested message types create a naming scope but not a structural dependency β the output is a flat list of definitions, not a tree that mirrors how components are nested.For guidance on which pattern to use, see Appendix H. For detailed pipeline diagrams per domain, see Appendix C.
Conceptual Pipeline
See the glossary for definitions of each stage.
Renderers are serializers: they do not alter the data. They may make representation decisions (key ordering, layout style, syntax choice) within the target format's constraints. Format-specific rendering hints, provided via context, can guide these decisions.
3. Package Architecture
Package Roles
An Alloy package can take on one or more of the following roles:
Output/SourceDirectory, and shared types. (@alloy-js/core)@alloy-js/typescript,@alloy-js/python,@alloy-js/graphql)@alloy-js/json,@alloy-js/yaml)@alloy-js/graphqlis a hybrid β it provides both schema components and SDL/introspection renderers.)A package includes only the directories that apply to its roles (see Suggested Directory Layout).
Core (
@alloy-js/core)The runtime package. All domain-specific code moves out; core has no dependency on Prettier. See Appendix B for the current structure this refactors from.
Provides:
renderTree)ModelCollectorinterface (for evaluator-driven domains)Output,SourceDirectoryrender()andrenderAsync()functions (see Core's Role in Output Assembly)Domain Packages
@alloy-js/source(evaluator-driven) β the source-code model, extracted from today's core:SourceFilecomponentRenderedTextTreetypes, print hooks, formatting intrinsics (<indent>,<group>, etc.)isEmpty,createContentSlot, conditional formatting utilities)printTreefunction (Prettier integration)@alloy-js/data-model(evaluator-driven):DataFilecomponentDataModelTreetypes<Object>,<Array>,<Scalar>,<Property>) β see Appendix D for cross-renderer guarantees@alloy-js/definitions(component-driven):DefinitionScopecomponent (multi-file coordination)DefinitionFilecomponentDefinitionRegistry<T>(generic nameβdefinition map with deduplication)DefinitionStateinterface (contract for domain state implementations)Format and Language Packages
Language packages (
@alloy-js/typescript,@alloy-js/python,@alloy-js/csharp, etc.) remain component libraries. They now depend on@alloy-js/source(instead of core) for formatting primitives.Format packages (
@alloy-js/json,@alloy-js/yaml,@alloy-js/toml,@alloy-js/xml) are renderer packages that convertDataModelTreeto their document tree and then to string output. Optional format-specific components (e.g.,Yaml.LiteralStyle) live alongside the renderer.Domain-specific packages (
@alloy-js/graphql,@alloy-js/sql,@alloy-js/protobuf) provide domain state implementingDefinitionState, definition components that register into that state, and domain-specific renderers (SDL, DDL,.protosyntax). They useDefinitionFileandDefinitionScopefrom@alloy-js/definitions.Suggested Directory Layout
Packages include only the directories that apply:
src/components/β JSX surface area (component creators)src/model/β model tree types, builders, normalizationsrc/collector/β model collector implementationsrc/renderer/β model β document β string pipelinesrc/document/β document tree types and helperssrc/context/,src/builtins/,src/symbols/β as used today4. File Components and Serialization
Each output category has its own file component that establishes the rendering domain for its subtree:
SourceFile@alloy-js/sourcepath,filetype,printOptionsDataFile@alloy-js/data-modelpath,formatDefinitionFile@alloy-js/definitionspath, domain configDefinitionFilemust be wrapped in aDefinitionScopefor multi-file coordination.Lazy Serialization Convention
Each file component stores a serializer callback in
context.meta.file:Serialization happens once after all reactive updates settle, not on every component update. Each file component provides its own serializer without core depending on any domain package.
Example β
SourceFile:Core's Role in Output Assembly
The top-level
render()function in core:context.meta.fileandcontext.meta.directorymeta.file.serialize(options)for each file to produce outputOutputDirectorystructureCore is completely generic β it knows nothing about text vs. data vs. definitions.
Mixed-Domain Output
A single
Outputcan contain files with different model types and collection strategies in the same render pass:5. Format-Specific Components
Format packages (
@alloy-js/yaml,@alloy-js/xml,@alloy-js/toml) export components that carry format-specific information. These fall into two categories.Rendering Hints
Rendering hints control how data is represented in a particular format without changing what the data is. They are context providers wrapping generic content. The matching renderer reads the context; non-matching renderers ignore it and process children as-is. Data is never lost.
Examples:
YAML string style:
Yaml.LiteralStylewraps aScalar. The YAML renderer uses|block style. Other renderers output the string normally.YAML block vs. flow:
Yaml.FlowStylewraps anObjectorArray. The YAML renderer uses inline{}/[]syntax. Other renderers output the structure normally.TOML inline table:
Toml.InlineTablewraps anObject. The TOML renderer uses{key = value}syntax instead of a[table]header. Other renderers output the object normally.XML attributes:
Xml.AsAttributewrapsPropertynodes. The XML renderer emits them as element attributes. Other renderers output the properties normally.The same component tree produces format-appropriate output from each renderer. The XML renderer honors the
Xml.AsAttributehint; the JSON renderer ignores it and emits the property normally. The data is identical β only the representation changes:XML:
JSON:
{"id": 42, "body": {...}}XML element naming:
Xml.ItemElementwraps anArrayto specify the element name for each item.XML namespaces:
Xml.Namespacewraps elements to bind a namespace prefix.Multiple format hints can wrap the same content without conflict β each renderer reads only the contexts it understands. See Appendix F for how mismatched components are handled.
Format Metadata
Format metadata is information about the format itself: XML processing instructions (
<?xml ...?>), YAML document markers (---), TOML comments, DTD declarations. These have no generic equivalent. When a non-matching renderer encounters them, it produces no output for that subtree β this is expected behavior.Shared References (Refkeys)
YAML anchors/aliases, JSON
$refpointers, and similar cross-reference mechanisms are serialization-specific representations of a generic concept: shared references. Alloy already provides this via refkeys.Rather than requiring format-specific anchor/alias components, a format package provides a rendering context that controls how refkeys are serialized. The data model itself uses refkeys; the format decides how to represent them.
The Pattern
Format-specific components are context providers following two rules:
6. Open Decisions / Questions
ModelCollectorinterface? The text collector and data model collector have very different needs (text appends strings; data model builds structure from components). The interface must be narrow enough to be generic but expressive enough that collectors aren't fighting it.RenderedTextTreearray β the collector interface needs an equivalent mechanism.DefinitionStaterequire? At minimum:register(),resolve(),serialize(), but are there other common operations?DefinitionScope? Does it automatically merge states from multiple files, or do domain packages implement merging logic?DefinitionRegistrystate for inspection?SourceFileContext.reference. In a data model, what does a refkey produce? (JSON$ref? Repeated values?)@alloy-js/sourceis a breaking change for existing users.Appendices
A. Glossary
Children,ComponentCreators, primitives). The input to the conceptual pipeline.DataModelTree,RenderedTextTree,GraphQLSchema). May be a tree or a graph/flat collection. Some models support multiple renderers; others are coupled to a single renderer. Built by a model collector or by component-driven collection.Prettier.Doc, YAML node tree, GraphQLDocumentNode). The intermediate step between model and output text.ModelCollectorthat assembles them into a tree structure. Used when output structure mirrors component tree structure. See Two Collection Patterns and Appendix H.B. Current Alloy Structure
These are the parts of the current codebase that the core design refactors:
renderTreebuilds aRenderedTextTreefromChildren,printTreeproduces aPrettier.Doc, andrenderconverts that intoOutputDirectory/files. Seepackages/core/src/render.ts.ComponentCreatorthunk, not aReactElement-like object. The renderer invokes the thunk during traversal. Seepackages/core/src/jsx-runtime.tsandpackages/core/src/runtime/component.ts.packages/core/src/runtime/intrinsic.tsandpackages/core/src/print-hook.ts.OutputandSourceFile, which attach metadata into the render context. Seepackages/core/src/components/Output.tsxandpackages/core/src/components/SourceFile.tsx.@vue/reactivityand updates the rendered tree when dependencies change. Seepackages/core/src/render.ts.C. Detailed Pipeline Examples
See Conceptual Pipeline for the generic form.
Source Code (Evaluator-Driven)
Data/Config Formats (Evaluator-Driven)
Where
[format]is one of: JSON (value/object/array nodes), YAML (scalar/sequence/mapping), TOML (tables, arrays-of-tables, key paths), or XML (elements/attributes).Schema/Definition Languages (Component-Driven)
For GraphQL:
SchemaStateβDocumentNode(SDL) or introspection JSON. For SQL:SqlSchemaStateβ DDL statements. For Protobuf:ProtoStateβ.protosyntax.D. Data Model Component Guarantees
The generic data components (
<Object>,<Array>,<Scalar>,<Property>from@alloy-js/data-model) explicitly document which semantic properties they preserve across renderers. Guarantees are based on the lowest common denominator of renderer support: if a component guarantees a property, every format renderer must honor it. If not, renderers are free to handle it as the target format requires.The set of guarantees is driven by what all supported data formats can uphold. A guarantee that any renderer would need to violate should not be made.
Example β ordering. An
<Array>guarantees item order is preserved: every target format supports ordered sequences, so renderers must respect it. An<Object>does not guarantee key order: JSON specifies that key order is not significant, and TOML requires key reordering to produce valid output (bare key-value pairs before sub-tables). Because not all renderers can preserve key order, the generic<Object>makes no such promise. A user who needs ordered keys reaches for a format-specific component.E. Comparison to React/Vue Architecture
Where it aligns:
react-dom,react-native,react-test-renderer). TheModelCollectorplays the same role: different collectors tell the same generic evaluator how to construct different model trees.DataModelTreebuilding feel natural.Where it differs:
ReactElementtree then reconciles to a UI host. Alloy does not build aReactElementequivalent; JSX yields aComponentCreatorthunk andrenderTreeexecutes it.ModelCollectoris simpler because it builds static artifacts, not a live instance graph. There is no commit phase or reconciliation.F. Renderer Mixing and Error Behavior
G. Additional Use Cases
Because renderers preserve the model's data (they may choose representation but do not alter semantic content):
H. Choosing a Collection Pattern
See Two Collection Patterns for full definitions. This appendix provides a quick decision guide.
Use evaluator-driven collection when:
Use component-driven collection when:
I. Migration Impact
This appendix summarizes what changes for each existing package.
@alloy-js/coreβ becomes a pure runtime package. Text-specific code is extracted:RenderedTextTreetypes, print hooks, formatting intrinsics (<indent>,<group>, etc.), content tracking (isEmpty,childrenWithContent,createContentSlot),printTree, andSourceFileall move to@alloy-js/source. The Prettier dependency is removed from core. Everything that remains β JSX runtime, reactivity, context,Output,SourceDirectory, diagnostics, devtools, refkeys β is already generic. This is a breaking change: code that imports text-specific symbols from@alloy-js/coremust update imports to@alloy-js/source.@alloy-js/source(new) β receives all text-specific code extracted from core. This is not new functionality; it is a direct extraction of the currentrenderTree/printTreepipeline into its own package. Existing behavior is preserved.Language packages (
@alloy-js/typescript,@alloy-js/python,@alloy-js/csharp, etc.) β remain component libraries. Their dependency on@alloy-js/corefor formatting primitives (SourceFile,<indent>, content tracking) changes to a dependency on@alloy-js/source. No component logic changes; only import paths shift.@alloy-js/graphqlβ currently works around the coupledrenderTreeby calling it for its side effect and discarding theRenderedTextTree. After the refactor,renderTreeaccepts no collector for component-driven domains, making the workaround unnecessary.@alloy-js/graphqlcan adoptDefinitionFileandDefinitionScopefrom@alloy-js/definitionsfor multi-file coordination, and implementDefinitionStatefor itsSchemaState. ExistingrenderSchemabehavior is preserved.@alloy-js/data-model(new) β new package providingDataFile,DataModelTree, and generic data components (<Object>,<Array>,<Scalar>,<Property>). No migration needed; this is additive.@alloy-js/definitions(new) β new package providingDefinitionScope,DefinitionFile,DefinitionRegistry<T>, andDefinitionState. No migration needed; this is additive. Existing component-driven packages like@alloy-js/graphqlcan adopt it incrementally.Format packages (
@alloy-js/json,@alloy-js/yaml,@alloy-js/toml,@alloy-js/xml) β new renderer packages. No migration needed; these are additive.