Skip to content

View

@modularityjs/view is a typed component registry and recursive view validator. Apps register Component definitions (name + optional fields + optional slots) into a pool; the framework validates that a given ComponentInstance tree conforms to the registered components at boot time and at the application's request.

It's a building block for headless CMS-style features (page builders, content blocks, email templates) where the backend owns the types of content and the frontend owns the rendering — output is structured JSON, not HTML.

Concepts

Component

A registered building block. May have data fields, may have nested slots, may have both.

typescript
interface Component {
  readonly name: string;
  readonly fields?: readonly ComponentField[];
  readonly slots?: readonly ComponentSlot[];
}

ComponentField

A piece of typed data carried by a component instance.

typescript
interface ComponentField {
  readonly name: string;
  readonly type: ComponentFieldType;
  readonly required?: boolean;
  readonly maxLength?: number;
  readonly enumValues?: readonly string[];
}

type ComponentFieldType =
  | 'string'
  | 'number'
  | 'boolean'
  | 'url' // String with http: or https: protocol
  | 'enum' // String constrained to enumValues
  | 'json'; // Free-form object

ComponentSlot

A position where child components can be nested.

typescript
interface ComponentSlot {
  readonly name: string;
  readonly allowedComponents?: readonly string[]; // Component names permitted here
  readonly maxComponents?: number; // Cap on children
}

If allowedComponents is omitted, any registered component may appear in the slot. If maxComponents is omitted, the slot is unbounded.

ComponentInstance

A node in the validated tree. The shape carried over the wire and validated against the registry.

typescript
interface ComponentInstance {
  readonly type: string; // Must resolve to a registered component
  readonly data?: Record<string, unknown>;
  readonly slots?: Record<string, readonly ComponentInstance[]>;
}

Module

typescript
import { Module } from '@modularityjs/modularity';
import type { Component } from '@modularityjs/view';
import { ComponentsPool, ViewModule } from '@modularityjs/view';

const hero: Component = {
  name: 'hero',
  fields: [
    { name: 'title', type: 'string', required: true },
    {
      name: 'variant',
      type: 'enum',
      required: true,
      enumValues: ['light', 'dark'],
    },
    { name: 'link', type: 'url' },
  ],
};

const richtext: Component = {
  name: 'richtext',
  fields: [{ name: 'html', type: 'string', required: true, maxLength: 500 }],
};

const columns: Component = {
  name: 'columns',
  slots: [
    { name: 'left', allowedComponents: ['richtext'] },
    { name: 'right', maxComponents: 2 },
  ],
};

@Module({
  name: 'my-components',
  imports: [ViewModule],
  pools: [
    { pool: ComponentsPool, key: 'hero', useValue: hero },
    { pool: ComponentsPool, key: 'richtext', useValue: richtext },
    { pool: ComponentsPool, key: 'columns', useValue: columns },
  ],
})
class MyComponentsModule {}

Validation

Inject ViewService and call validate() with the root of a content tree. The validator walks the tree recursively and accumulates ValidationError[] for every issue it finds, then throws a single ValidationException (auto-mapped to HTTP 422 by FrameworkExceptionFilter).

typescript
import { Inject, Injectable } from '@modularityjs/di';
import { ViewService } from '@modularityjs/view';
import type { ComponentInstance } from '@modularityjs/view';

@Injectable()
class PreviewController {
  constructor(@Inject(ViewService) private readonly view: ViewService) {}

  validate(root: ComponentInstance) {
    this.view.validate(root); // Throws ValidationException on failure
    return { ok: true };
  }
}

What the validator checks

  • Unknown component type — every instance.type must resolve to a registered component.
  • Slot name — every key in instance.slots must match a slot declared on the component.
  • Slot constraintsallowedComponents and maxComponents are enforced.
  • Required fields — fields marked required: true must be present (non-undefined, non-null).
  • Field typesstring / number / boolean / enum / json / url (the url type also enforces http: or https: protocol).
  • maxLength — string fields with a configured cap.
  • enumValues — enum fields must be in the declared set.
  • No slots on slot-less components — a component with no slots declaration cannot have an instance.slots value.

Errors carry a structured field path like root.slots.header[0].data.variant, so consumers can show inline messages on the offending node.

Boot-time checks

ViewModule.onInit() fails boot if any of the registered components are inconsistent. This catches misconfiguration before the app starts serving requests:

  • Duplicate component names — two components in the pool sharing a name.
  • Enum field with no values — a field of type: 'enum' missing the enumValues array.
  • allowedComponents references unknown component — a slot's allowedComponents listing a name no other component provides.

All errors are accumulated and reported in a single ValidationException.

listComponents()

Returns the pool's full set of registered components. Useful for admin UIs that need to know what's available, or for emitting a JSON Schema description of the registry.

typescript
const components = this.view.listComponents();
// → readonly Component[]

See also

  • apps/view-preview — a two-pane POC: type a ComponentInstance JSON on the left, see the rendered output (or validation errors) on the right. Demonstrates the validator + a renderer-side warning path for components the frontend hasn't implemented.