Skip to main content

GraphQL Content Delivery overview

Overview
Link copied!

The GraphQL Content Delivery API is designed to optimize content delivery by providing the tools to eliminate under fetching and over fetching, allowing you to find the exact content you want and only return the content you need.

The GraphQL Content Delivery API has been designed to support complex experience management efficiently. An experience can retrieve all the content it needs using less requests, less data and less bandwidth.

The Amplience GraphQL Content Delivery API also provides the following benefits:

  • Experiences that are optimised for speed and scalability due to the underlying NoSQL architecture of the Amplience CMS - Dynamic content.
  • Spending less time managing your GraphQL schema with automatic schema generation and publishing.
  • Only publishing graphs if they are valid, preventing breaking changes from causing system downtime.
  • Manual overrides are provided to give developers the freedom to define their own type names and to exclude unwanted content item types from their graphs.

Setting up your GraphQL environment
Link copied!

info

In order to use the GraphQL Content Delivery API, Content Delivery 2 must be enabled. If you want to use the GraphQL Content Delivery API but don’t have Content Delivery 2 enabled already, contact your Amplience Customer Success Manager.

Using the GraphQL API endpoint
Link copied!

An API endpoint exists for each hub, and this will allow users to query the latest published graph for the corresponding hub.

Endpoints are created in a standard syntax, which is as follows:

https://[hub-name].cdn.content.amplience.net/graphql

For example, if a hub were named “ampproduct-doc”, the endpoint would be:

https://ampproduct-doc.cdn.content.amplience.net/graphql

These are public endpoints and therefore do not require any form of authentication (i.e. credentials or tokens) to query them.

GraphQL API playground
Link copied!

As the graphs are generated using GraphQL, it is possible to use any GraphQL playground of your choice. GraphQL playgrounds can be used with Amplience graphs by pointing your playground environment to the graph’s API endpoint.

Examples: Trying out GraphQL
Link copied!

Once you have setup your API Playground, you can try running a few simple GraphQL queries. As an example, let's try querying for the title, description and read time of the blog post content type below.

The content item we're querying for

To query this blog post in a GraphQL playground, we need either the delivery key or the content id. In the example query below, we're using the delivery key.

The GraphQL query and result

query {
blogPost(deliveryKey: "exploring-sap-integration-approaches") {
title
description
readTime
}
}

As shown below, our query returns a GraphQL format result that includes the title of our blog post, the description and the read time:


{
"data": {
"blogPost": {
"title": "Exploring SAP & Amplience Integration Approaches",
"description": "In this post we’ll discuss the different integration types that exist between SAP and Amplience and which might make the most sense for you and your business.",
"readTime": 2
}
}
}

Working with graphs in GraphQL
Link copied!

GraphQL creation and publishing
Link copied!

Dynamic content automatically creates and publishes a GraphQL schema, known as a graph, for each Content Delivery 2 enabled hub. Each graph can then be queried via a dedicated API endpoint.

The creation of a graph is initiated when any content type in a Content Delivery 2 enabled hub is registered, synchronized, archived or unarchived. See the syncing a content type with its schema page to learn more about syncing a content type with its schema.

All content types registered on the hub are included by default in the schema. This includes, content, slots and partials, and unregistered types referenced by other types.

Once the graph has been created, it will automatically be published and available to query via the API endpoint.

note

It typically takes around 5 minutes from initiation to having a published graph that can be queried.

Updating an existing graph
Link copied!

Dynamic content automatically updates each hub’s API endpoint with the latest published version of the graph for that hub.

A new graph is created and published for a hub every time a content type in that hub is registered, synchronized, archived or unarchived.

Graph conversion and GraphQL type names
Link copied!

Graphs are created by converting all content type schema for a given hub into a single GraphQL schema, known as a graph. The translation from content type schema (JSON) to a single graph (GraphQL schema) is automatically done by Dynamic content.

This is a 2-step process. Step 1 is to resolve a content type schema name from the content type schema ID and step 2 is converting this name to a GraphQL type name.

Step 1: Resolving a content type schema name
Link copied!

Each content schema type uses a URI as a unique identifier, the schema ID. To reduce the complexity of GraphQL type names only part of the schema ID URI is used to define the content schema type name.

Specifically, the content schema type name is resolved from the last part of the URI Path(i.e. after the last “/”), any file type extension names are then removed.

Some examples of this process are shown below:

content type schema IDcontent type schema Name
https://example.com/Banner-1 Banner-1
https://example2.com/Banner-1Banner-1
https://example.com/Banner.JSONBanner
https://example.com/Example/SS22/_banner_banner

Step 2: Converting content type schema name to GraphQL type name
Link copied!

A set of rules are applied to translate the content type schema name to a valid GraphQL type name, the rules are as follows.

  • Replace unsupportable chars & other separators (., space, -) with _
  • Camel case based on separators
  • Replace multiple leading underscores with a single underscore
  • If name starts with digit, prefix with _
  • Prefix reserved names with _ or __

Some examples of these rules being applied are shown below:

content type schema NameGraphQL type name
Banner-1Banner1
BannerBanner
__bannerBanner
1banner _1banner
Id-banner IdBanner
Query_Query

GraphQL type name collision
Link copied!

It is possible that the [type name translation](#GraphQL-type name-collision) process may generate the same GraphQL type name for more than one content type schema, this collision leads to an invalid graph that cannot be published. The last valid graph will remain published at the API endpoint until the collision is resolved.

The most common scenarios where a collision may occur are:

  • A hub has multiple content schema types with the same name
  • A hub has multiple content schema types where their names only differ by what separator type, they use.

For example: content type schema with an ID of https://example.com/content/banner and https://example.com/slot/banner would both lead to a GraphQL type name of Banner.

If type name collisions do occur, manual intervention is required to resolve them before a new valid graph can be created and published for that hub. This can be done by either excluding content type schema from the graph or adding GraphQL type name overrides to content type schema.

note

If a graph fails to be created or updated as expected it is likely a name collision has occurred. Users should review the names of their content type schema to identify collisions and manually resolve them.

Excluding content type schema from a graph
Link copied!

It is possible for users to manually exclude specific content type schema from a hub’s graph. This needs to be defined for each content type schema that is to be excluded.

The graphql:skip directive can be used in a content type schema to identify if it should be excluded when building the hub’s graph. By default this directive is not included in content type schema.

The directive accepts only a boolean value. The following table describes the different configuration options for this:

ConfigurationResult
Not included in content type schema (default behaviour)content type schema included in graph
graphql:skip: [any value other than true]content type schema included in graph
graphql:skip: truecontent type schema is not included in graph

The following is an example content type schema that has been configured to be excluded from the hub’s graph.


{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://demostore.amplience.com/slots/banner",
"title": "Banner Slot",
"graphql:typename": "BannerSlot",
"graphql:skip": true,
"description": "A slot to contain a Banner.",
"allOf": [
{
"$ref": "http://bigcontent.io/cms/schema/v1/core#/definitions/content"
}
],
"type": "object",
"properties": {...},
"propertyOrder": []
}

It is also possible to use the graphql:skip directive to exclude specific properties from a graph. To set at the property level, simply include the directive you wish to exclude.

The following is an example of the directive being used at a property level:



{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://demostore.amplience.com/slots/banner",
"title": "Banner Slot",
"description": "A slot to contain a Banner.",
"allOf": [
{
"$ref": "http://bigcontent.io/cms/schema/v1/core#/definitions/content"
}
],
"type": "object",
"properties": {
"content": {
"graphql:skip": true,
"title": "content",
"allOf": [
{
"$ref":
"http://bigcontent.io/cms/schema/v1/core#/definitions/content-link"
},
{
"properties": {...}
}
]
}
},
"propertyOrder": []
}

GraphQL type name overrides
Link copied!

The GraphQL Content Delivery API has been designed to automatically generate, update and publish graphs. As part of this design GraphQL type names are automatically defined by Dynamic content.

However, it is possible for users to manually define their own GraphQL type names for content type schema if they wish. type name overrides can be defined on a content type schema using the graphql:typename directive.

If a user includes this directive the value defined against it will be used to generate the GraphQL type name.

The user specified value is evaluated during graph creation to ensure the syntax is valid, the following rules are automatically applied to ensure a valid type name is created.

  • Replace unsupportable chars & other separators (., space, -) with _
  • Replace multiple leading underscores with a single underscore
  • If name starts with digit, prefix with _
  • Prefix reserved names with _ or __

The following example shows how the graphql:typename directive could be used to specify a GraphQL type name of BannerSlot for a specific content type schema.


{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://demostore.amplience.com/slots/banner",
"title": "Banner Slot",
"graphql:typename": "BannerSlot",
"description": "A slot to contain a Banner.",
"allOf": [
{
"$ref": "http://bigcontent.io/cms/schema/v1/core#/definitions/content"
}
],
"type": "object",
"properties": {...},
"propertyOrder": []
}

note

It should also be noted that when a content type schema is archived, this also archives the GraphQL schema.

It is also possible to use the graphql:typename directive to exclude specific properties from a graph. To set at the property level, simply include the directive in the property you wish to exclude.

The following is an example of the directive being used at a property level:


{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "https://raw.githubusercontent.com/amplience/dc-static-blog-nextjs/master/schemas/
text.json",
"title": "Text",
"description": "Text schema",
"allOf": [
{
"$ref": "http://bigcontent.io/cms/schema/v1/core#/definitions/content"
}
],
"type": "object",
"properties": {
"text": {
"type": "string",
"format": "markdown",
"graphql:typename": "TextOverride",
"title": "Text",
"description": "",
"minLength": 1,
"maxLength": 30000
}
},
"propertyOrder": ["text"],
"required": ["text"]
}

Raw JSON Property Helper
Link copied!

The underlying content model is made up of JSON content. A helper is provided as part of the GraphQL schema Delivery API to allow users to return the raw JSON for a given property as part of a GraphQL request.

To return the raw JSON for a property a user can use the GraphQL schema creationProperty(propertyName: $propertyname) field, which is available for every GraphQL type.

A value needs to be passed into the rawProperty field in order to identify which property should have its raw JSON returned.The following example shows of how this helper can be used to return the raw JSON for a given property, in this case the background property:

query {
tutorialBanner(deliveryKey: "banner-example") {
rawProperty(propertyName: "background")
}
}

The above query returns the below response, including data pertaining to the tutorialBanner content:

{
"data": {
"tutorialBanner": {
"rawProperty": {
"image": {
"_meta": {
"content type schema": "http://bigcontent.io/cms/schema/v1/core#/definitions/image-link"
},
"id": "dff04dbe-8664-4531-bc9a-60e6f5df6fab",
"name": "Hero-Banner-720-model2",
"endpoint": "ampproduct",
"defaultHost": "cdn.media.amplience.net"
},
"alt": "Model wearing summer hat and dress"
}
}
}
}

It is possible to return raw JSON for given properties alongside non-raw JSON in the same query. The following screenshot shows an example of this:

query {
tutorialBanner(deliveryKey: "banner-example") {
strapline
rawProperty(propertyName: "background")
}
}

The above query gives us the following response, providing the raw JSON for our given properties alongside our non-raw JSON:

{
"data": {
"tutorialBanner": {
"strapline": "A chance to update your wardrobe",
"rawProperty": {
"image": {
"_meta": {
"schema": "http://bigcontent.io/cms/schema/v1/core#/definitions/image-link"
},
"id": "dff04dbe-8664-4531-bc9a-60e6f5df6fab",
"name": "Hero-Banner-720-model2",
"endpoint": "ampproduct",
"defaultHost": "cdn.media.amplience.net"
},
"alt": "Model wearing summer hat and dress"
}
}
}
}

Raw JSON content item Helper
Link copied!

The underlying content model is made up of JSON type.

A helper is provided as part of the GraphQL Content Delivery API to allow users to return the raw JSON for an entire content item as part of a GraphQL request.

To return the raw JSON for an entire content item a user can use the rawJson{content} field, which is available for every GraphQL GraphQL. The following example shows how this helper can be used to return the raw JSON for a given property, in this case the background property.

query TutorialBanner {
tutorialBanner(deliveryKey: "banner-example") {
rawJson {
content
}
}
}

The above query gives us the following response:

{
"data": {
"tutorialBanner": {
"rawJson": {
"content": {
"background": {
"image": {
"_meta": {
"schema": "http://bigcontent.io/cms/schema/v1/core#/definitions/image-link"
},
"id": "dff04dbe-8664-4531-bc9a-60e6f5df6fab",
"name": "Hero-Banner-720-model2",
"endpoint": "ampproduct",
"defaultHost": "cdn.media.amplience.net"
},
"alt": "Model wearing summer hat and dress"
},
"link": {
"url": "https://amplience.com/developers/docs",
"title": "Buy now"
},
"_meta": {
"name": "Code samples- tutorial banner",
"schema": "https://schema-examples.com/tutorial-banner",
"deliveryKey": "banner-example",
"deliveryId": "1f4026cd-9694-4a64-ac3e-e58e5ebde992"
},
"strapline": "A chance to update your wardrobe",
"headline": "Surprise sale now on!"
}
}
}
}
}

It is possible to return raw JSON for a given content item alongside non-raw JSON in the same query. The following screenshot shows an example of this:

query TutorialBanner {
tutorialBanner(deliveryKey: "banner-example") {
rawJson {
content
}
}
blogPost(deliveryKey: "exploring-sap-integration-approaches") {
authors {
name
}
title
description
readTime
}
}

The above query gives us the following response, providing the raw JSON for our given properties alongside our non-raw JSON:

{
"data": {
"tutorialBanner": {
"rawJson": {
"content": {
"background": {
"image": {
"_meta": {
"schema": "http://bigcontent.io/cms/schema/v1/core#/definitions/image-link"
},
"id": "dff04dbe-8664-4531-bc9a-60e6f5df6fab",
"name": "Hero-Banner-720-model2",
"endpoint": "ampproduct",
"defaultHost": "cdn.media.amplience.net"
},
"alt": "Model wearing summer hat and dress"
},
"link": {
"url": "https://amplience.com/developers/docs",
"title": "Buy now"
},
"_meta": {
"name": "Code samples- tutorial banner",
"schema": "https://schema-examples.com/tutorial-banner",
"deliveryKey": "banner-example",
"deliveryId": "1f4026cd-9694-4a64-ac3e-e58e5ebde992"
},
"strapline": "A chance to update your wardrobe",
"headline": "Surprise sale now on!"
}
}
},
"blogPost": {
"authors": [
{
"name": "Adam Sturrock"
}
],
"title": "Exploring SAP & Amplience Integration Approaches",
"description": "In this post we’ll discuss the different integration types that exist between SAP and Amplience and which might make the most sense for you and your business.",
"readTime": 2
}
}
}

Filter by content type queries
Link copied!

It is possible to build GraphQL queries that filter for content from a given content type using the GraphQL Content Delivery API. Pagination is used alongside this functionality to manage the query responses.

note

Only content that has been published after 16/11/2022 is available for this content to query. If you intend to use this feature we recommend you ensure the content you will be querying has been published after this date.

During the schema generation, a GraphQL query is created for each content type which can be used to query for specific content of that type using a delivery id or delivery key.

At the same time, a GraphQL query is created to allow users to filter by content type. The GraphQL types created to be used for filter by type queries prefix the single GraphQL type name with “all” and camelCase the single GraphQL type name.

Some examples below:

content type schema nameGraphQL queryGraphQL Filter By query
authorauthorallAuthor
BlogPostblogpostallBlogPost
MediamediaallMedia

The filter by query uses the GraphQL cursor connections specification to allow pagination of results.

Arguments
Link copied!

Filter by content type queries support four different arguments to be passed in, these are':

Argument nameDescription
firstShow the first [n] results, user defines [n].
lastShow the last [n] results, user defines [n].
beforePasses a cursor as a variable and is combined with either the “first” or “last” argument to return the first/last [n] results before the given cursor.
afterPasses a cursor as a variable and is combined with either the “first” or “last” argument to return the first/last [n] results after the given cursor)
note

A filter by type query requires either the “First” or “Last” argument to be defined in order for the query to be valid.

Fields
Link copied!

Filter by content type queries support 2 different fields, these are:

Argument nameDescription
edgeThis field is used to specify which fields to return for each content item of that type. Further details of how to use this field are described below.
pageInfoThis field is used to return pagination information.

Edge
Link copied!

The edge field is used to specify which fields to return for each content item of that type. The “edge” field is made up of two further fields, see below:

Argument nameDescription
cursorThis field is used to return a cursor for each content item for the given content type.
nodeThis field is used to specify fields from the content item to return. This field contains all fields the corresponding single GraphQL type contains.

Page Info
Link copied!

The “pageInfo” field is used to return pagination information. The “pageInfo” field is made up of four further fields, see below:

RuleLimitNotes
endCursor8KB If a request exceeds this limit, the query will not be run and an error will be returned.
hasPreviousPage1MB If a request exceeds this limit, the query will not be run and an error will be returned.
hasNextPage22Dynamic content provides the ability to nest content items, the ability to nest is restricted to a depth of 22.
startCursor10 secondsThe response timeout limit for a query is 10 seconds. If a query has not completed within this time, the query will be ended, and an error will be returned.

Examples of filter queries
Link copied!

See below for a number of different examples using the fields above.

Return first two names in list of author names from author type
Link copied!

query {
allAuthor(first: 2) {
edges {
node {
Name
}
}
}
}

The above query gives us the following GraphQL response:

{
"data": {
"allAuthor": {
"edges": [
{
"node": {
"Name": "Nick Piper"
}
},
{
"node": {
"Name": "DarrenLee"
}
}
]
}
}
}

Return last two items in list of author names from author type
Link copied!

query {
allAuthor(last: 2) {
edges {
node {
Name
}
}
}
}

The above query gives us the following GraphQL response:

{
"data": {
"allAuthor": {
"edges": [
{
"node": {
"Name": "Oliver Secluna"
}
},
{
"node": {
"Name": "John Williams"
}
}
]
}
}
}

Return list of authors and show end cursor for page
Link copied!

query {
allAuthor(last: 2) {
edges {
node {
Name
}
}
pageInfo {
endCursor
}
}
}

The above query gives us the following GraphQL response:

{
"data": {
"allAuthor": {
"edges": [
{
"node": {
"Name": "Oliver Secluna"
}
},
{
"node": {
"Name": "John Williams"
}
}
],
"pageInfo": {
"endCursor": "eyJzb3J0S2V5IjoiNjM2YzBkZDg1ODdiMDEwMDAxOTE0ZGY3IiwiaXRlbUlkIjoiZ3FsZGVtb2h1Yjo3ZmU5Y2FmMi0xZmM4LTRhZWEtOWQwNi0wMmRiMjNiNGQzZmEifQ=="
}
}
}
}

Return first two items in list of authors after a given cursor
Link copied!

query {
allAuthor(first: 2, after: $allAuthorAfter2) {
edges {
node {
Name
}
}
}
}

The above query item the following GraphQL response:

{
"data": {
"allAuthor": {
"edges": [
{
"node": {
"Name": "Nick Piper"
}
},
{
"node": {
"Name": "DarrenLee"
}
}
]
}
}
}

Return last two items in list of authors before a given cursor
Link copied!

query {
allAuthor(
last: 2
before: "eyJzb3J0S2V5IjoiNjM2YzBkZDg1ODdiMDEwMDAxOTE0ZGY3IiwiaXRlbUlkIjoiZ3FsZGVtb2h1Yjo3ZmU5Y2FmMi0xZmM4LTRhZWEtOWQwNi0wMmRiMjNiNGQzZmEifQ=="
) {
edges {
node {
Name
}
}
}
}

The above query gives us the following GraphQL response:

{
"data": {
"allAuthor": {
"edges": [
{
"node": {
"Name": "Paul Murphy"
}
},
{
"node": {
"Name": "Oliver Secluna"
}
}
]
}
}
}

Usage
Link copied!

Methods
Link copied!

The GraphQL Content Delivery API supports the POST method. The query should be sent in the body of a POST request. The body must be in JSON format and the query should be in the following format:

{
"query": "YOUR GQL QUERY"
}

Status codes
Link copied!

Status codeDescription
200Request has returned data. If an error is generated alongside data a 200 will be returned.
400Non-specific error with request, usually due to an invalid query.
404schema not found.

In the case of multiple error the status code of the highest x00 bracket will be returned. For example, if there is a 400 and 500, then a 500 is returned.

Error response format
Link copied!

All error responses are generated in GraphQL format, as shown below:


{
"errors": [
{
"message": "<message>",
"extensions": {
"code": "<code>"
reason: “<reason>”
}
}
]
}

The message property is used to provide a description of the error that has occurred, and this is formatted as a sentence. This property will always be provided in an error response.

The code property is used to provide a corresponding error code for the error that has occurred. This property will always be provided in an error response.

The reason property provides additional context for why the error occurred. This property is formatted as a code and will only be returned when additional information needs to be provided.

Below is an example error generated when the complexity limit is exceeded. In this scenario, the reason property is returned:


{
"errors": [
{
"message": "Response body must not exceed 1MB.",
"extensions": {
"code": "COMPLEXITY_SCORE_EXCEED"
"reason": "RESPONSE_SIZE_LIMIT"
}
}
]
}


Below is an example error generated when the user provides both a delivery id and a delivery key. In this scenario the reason property is not returned:


{
"errors": [
{
"message":"Query `contentNode(deliveryId:String,
deliveryKey:String): contentNode` requires exactly one variable.",
"extensions":{
"code":"UNKNOWN_CODE"
}
}
]
}


Limits
Link copied!

Several different limits are enforced by the GraphQL Content Delivery API. This includes common request and response limits to prevent excessively large queries or queries that take a long time to run.

Also, the GraphQL Content Delivery API uses complexity scoring to mitigate the risk of extremely complex responses that would likely cause performance issues.

RuleLimitNotes
Request size8KB If a request exceeds this limit, the query will not be run and an error will be returned.
Response size1MB If a request exceeds this limit, the query will not be run and an error will be returned.
Depth limit22Dynamic content provides the ability to nest content items, the ability to nest is restricted to a depth of 22.
Response timeout10 secondsThe response timeout limit for a query is 10 seconds. If a query has not completed within this time, the query will be ended, and an error will be returned.
Complexity score1000A score of 1 point is assigned to every item that is returned by a query, the Complexity score is set at 1000. If a query's score exceeds this limit (i.e. more than 1000 items returned), the query will be ended, and an error will be returned.

Examples - with complexity score
Link copied!

Single content item query
Link copied!

The following query shown in the screenshot below has a score of 1 as it only returns content from a single content item.

query {
tutorialBannerSim(deliveryId: "ae25bdea-9cdc-4b23-939e-2198cfe74777") {
headline
}
}

The above query produces the following response:

{
"data": {
"tutorialBannerSim": {
"headline": "The Summer Collection"
}
}
}

Multiple content item query
Link copied!

The following query has a score of 3 as it returns content from three content items with no linked content.

query {
tutorialBannerSim(deliveryId: "ae25bdea-9cdc-4b23-939e-2198cfe74777") {
headline
}
machdemo(deliveryId: "5b3d932c-2ccf-472f-9875-be07b3691318") {
body
}
tutorialbanner(deliveryId: "ae25bdea-9cdc-4b23-939e-2198cfe74777") {
calltoactionurl
}
}

The above query produces the following response:

{
"data": {
"tutorialBannerSim": {
"headline": "The Summer Collection"
},
"machdemo": {
"body": "Example content item for GQL Demo"
},
"tutorialbanner": {
"calltoactionurl": "http://www.amplience.com/docs"
}
}
}

Multiple content item query with linked content
Link copied!

The following query has a score of 3 as it returns content from two content items and one of these content items includes linked content.

query {
carousel(deliveryId: "967b485e-c37c-48e9-bee2-4e84be11cb3e") {
slides {
image {
id
}
}
}
simplebanner(deliveryId: "88225dae-5aac-419f-ab87-c6822a6448e2") {
headline
}
}

The above query produces the following response:


"data": {
"carousel": {
"slides": [
{
"image": {
"id": "a76b51d3-0c80-436f-9bb5-a72c46cc5d59"
}
},
{
"image": {
"id": "69f8d25a-c178-4f8f-a223-7002139a5538"
}
},
{
"image": {
"id": "b926c31d-9092-4f8e-b14d-e0c7f311bb3e"
}
},
{
"image": {
"id": "549cb75b-6388-4b3d-bb85-f8c57fd65a08"
}
}
]
},
"simplebanner": {
"headline": "simple"
}
}
}

Notes
Link copied!

We'll be adding several features in forthcoming releases, including:

  • Custom sorting functionality
  • Filtering on additional parameters
  • Returning entire hierarchies
  • Field level localization
  • An integrated GraphQL Playground
  • Support for unpublished content to enable visualization