@qoh/core-angular
v1.0.0
Published
**Angular client library for [headless.li](https://www.headless.li) — the Semantic Layer for AI Agents.**
Keywords
Readme
@qoh/core-angular
Angular client library for headless.li — the Semantic Layer for AI Agents.
Build CMS-driven websites with zero hallucinations at a fraction of the (token) cost.
Stop fighting GraphQL. Start shipping.
headless.li is a middleware platform that connects GraphQL-based headless CMS systems to your Angular components — without manually writing any GraphQL queries. Works with:
Drupal · WordPress · AEM · Sitecore · Contentstack · Hygraph · Strapi · Payload · Sanity · Directus (and any other GraphQL-based CMS)
Register your components once against CMS type names, call service.query() with a query name
and slug, and add <queenofhearts-renderer /> to your template. The library resolves the right
component for each content block automatically based on __typename.
Installation
npm install @qoh/core-angularRequires Angular 19+, RxJS 7+, and Zod 4+ as peer dependencies.
Quick start
1. Provide your API token
In app.config.ts, add provideHttpClient() and your headless.li token:
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
{ provide: 'apiToken', useValue: 'your-headlessli-api-token' },
],
};You can get a free token at headless.li.
2. Register your components
Map each CMS content type (__typename) to the Angular component that should render it.
Call this once at startup — e.g. in a dedicated register-components.ts imported by your main.ts:
import { registerComponent } from '@qoh/core-angular';
import { HeroComponent } from './components/hero.component';
import { TextBlockComponent } from './components/text-block.component';
registerComponent(HeroComponent, 'HeroRecord');
registerComponent(TextBlockComponent, 'TextBlockRecord');3. Fetch page data
Inject QueenofheartsService and call query(). The service automatically sends the registered
component list to the API so only the fields your components actually use are returned.
import { Component, OnInit } from '@angular/core';
import { QueenofheartsService, Filter } from '@qoh/core-angular';
@Component({ ... })
export class PageComponent implements OnInit {
page: any;
constructor(private qoh: QueenofheartsService) {}
async ngOnInit() {
this.page = await this.qoh.query('page', {
variables: {
filter: [{ name: 'slug', operator: Filter.eq, value: 'home' }],
},
});
}
}4. Render the content
Add <queenofhearts-renderer /> to your template and import QueenofheartsRenderComponent.
It iterates the block array and renders the correct registered component for each item based on __typename.
import { QueenofheartsRenderComponent } from '@qoh/core-angular';
@Component({
standalone: true,
imports: [QueenofheartsRenderComponent],
template: `<queenofhearts-renderer [data]="page?.sections" />`,
})
export class PageComponent { ... }Component registration
Eager components
registerComponent(MyComponent, 'MyRecord');registerComponent accepts an optional Zod schema as the third argument for selective field fetching (see Zod schemas).
Lazy components
For code-splitting, register a dynamic import loader instead of the component directly:
import { registerLazyComponent } from '@qoh/core-angular';
registerLazyComponent(
() => import('./components/heavy-chart.component').then(m => m.HeavyChartComponent),
'ChartRecord',
);The loader is called the first time that component type is encountered and the result cached in the registry.
Zod schemas — selective field fetching
When you register a component with a Zod schema, the library derives a ComponentFieldMap from the
schema and sends it to the headless.li API. The API then restricts each component's query to only
the fields that schema describes, reducing payload size.
import { z } from 'zod';
import { registerComponent } from '@qoh/core-angular';
const HeroSchema = z.object({
__typename: z.string(),
headline: z.string(),
subline: z.string().optional(),
image: z.object({
url: z.string(),
alt: z.string().nullable(),
}),
});
registerComponent(HeroComponent, 'HeroRecord', HeroSchema);For union/polymorphic fields (an array of blocks of different types), use .loose() on the object —
this signals to the library that child component resolution should be delegated:
const PageSchema = z.object({
__typename: z.string(),
sections: z.array(z.object({ __typename: z.string() }).loose()),
});Without a schema the library requests all fields ({ __all: true }).
Querying content
service.query(queryName, options?)
Fetches CMS records by query name. The query name corresponds to a root-level query in your CMS
GraphQL schema (e.g. page, blogPost, product).
const result = await this.qoh.query('page', {
variables: {
filter: [{ name: 'slug', operator: Filter.eq, value: 'about' }],
locale: 'en',
},
depth: 3,
});Options:
| Option | Type | Description |
|---|---|---|
| variables.filter | { name, operator, value }[] | Filter conditions |
| variables.locale | string | Locale code |
| variables | Record<string, unknown> | Any additional CMS-specific variables |
| ignoreProperties | string[] | CMS fields to exclude from the response |
| depth | number | Relation nesting depth |
| fieldArgs | Record<string, unknown> | Extra per-field arguments |
Filter operators (via the Filter enum):
import { Filter } from '@qoh/core-angular';
Filter.eq // field equals value
Filter.neq // field does not equal value
Filter.in // field is one of (value is comma-separated)
Filter.notIn // field is not one ofservice.queryGraphql(graphqlQuery)
Sends a raw GraphQL query string to the headless.li proxy. Use this for one-off queries that don't map to registered components.
const result = await this.qoh.queryGraphql(`
query {
allProducts {
id
title
}
}
`);Passing shared data to all components
Use the [childData] input to inject shared data (locale, router state, feature flags, etc.) into
every component rendered by <queenofhearts-renderer />. Each component receives it via its
childData input.
<queenofhearts-renderer [data]="page.sections" [childData]="{ locale: 'en' }" />CMS backends
For CMS-specific response normalization, provide a backend adapter:
import { DatoCMSBackend } from '@qoh/core-angular';
// in app.config.ts providers:
{ provide: 'backend', useValue: new DatoCMSBackend() }Built-in adapters: DatoCMSBackend, StrapiCMSBackend.
Browser devtools
When the <body> element has the class qoh-inject-ids, the service enters debug mode. It injects
a __qohId attribute into every CMS block object and listens for custom window events emitted by
the headless.li browser devtools extension to support component
highlighting and data inspection.
API reference
| Export | Kind | Description |
|---|---|---|
| QueenofheartsService | Angular service | Injected automatically. Call .query() or .queryGraphql() to fetch content. |
| QueenofheartsRenderComponent | Angular component | Renders a CMS data array or object by dispatching to registered components. Selector: queenofhearts-renderer. |
| registerComponent | function | Registers an eager Angular component against a CMS type name. |
| registerLazyComponent | function | Registers a lazy-loaded component with a dynamic import loader. |
| Filter | enum | Filter operators: eq, neq, in, notIn. |
| zodToComponentFields | function | Converts a Zod schema to a ComponentFieldMap. |
| DatoCMSBackend | class | Response normalizer for DatoCMS. |
| StrapiCMSBackend | class | Response normalizer for Strapi. |
