tinacms-studio
Architecture
Stand-alone Admin
Tina Studio enables a stand-alone admin experience by managing the creation and setup of the TinaCMS React Microfrontend for the user using a vanilla JS API, and providing web standards API(s) for selecting entities to edit.
We've prototyped doing this by adding a mount
method to the CMS and using basic query param routing:
const tinaConfig = require('./tina.config')
const cms = new TinaStudio(tinaConfig)
cms.mount({ routing: true })
Mount takes the following configuration options:
type MountOptions = {
/* DOM element to mount the CMS to. Will create an element if omitted */
el?: Element;
/* Enable query param routing. Defaults to false */
routing?: boolean;
/* Render a client-side app in the admin using JSX */
render?: (window?: Window, document?: Document) => ReactNode;
}
Routing is explained in Entities.
Browser Bundle
Currently TinaCMS is built as a UMD library. This means it cannot be used in the browser as-is. To work around this, we forked @tinacms/scripts
as tinacms-plugin-utils
and updated it to have more control over console output using ink.js
and added support for building a UMD bundle, IIFE (Immediately Invoked Function Expression) browser bundle that mounts named exports to the window, and a ES module if desired for modern browsers, and packages direct dependencies inside it at build time, enabling usage in the browser.
This package is built with this tool, and exports the following:
-
dist/index.js
: a UMD bundle that extends TinaCMS with prototypes of the functionality outlined in this RFC -
dist/index.browser.js
: an IIFE that addsTinaCMS
andForm
under thetina
namespace
This allows us to include the browser bundle inside tools like Hugo and 11ty and use Tina off of the window:
<script src="https://unpkg.com/browse/react@17.0.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/browse/react-dom@17.0.1/umd/react-dom.production.min.js"></script>
<script src="/tina-studio/index.browser.js"></script>
<script>
const { TinaCMS, Form } = tina || window.tina;
const cms = new TinaCMS({ ... })
const form = new Form({ ... })
cms.plugins.add(form)
cms.mount({ routing: true })
</script>
Entities
Entities make TinaCMS aware of the content models of your content sources, such as Tina Cloud, Github, or other CMSes and databases.
Configuration
The following options are available for entities:
type EntityOptions {
/** Provide a unique name/id for the entity */
name: string;
/** Single for a single document, list for an array, or a graph representing a hierarchy */
type: "single" | "list" | "hierarchy"
/** The schema/base form template for this entity */
template: FormOptions<EntityShape> | ((record: EntityRecord, cms: TinaStudio) => FormOptions<EntityShape>)
/** Provide the records to include, or a function that resolves them */
include: EntityRecord | EntityRecord[] | (() => EntityRecord | EntityRecord[]);
/** Provide instructions for exlcuding the records returned by `include` */
exclude?: (record: EntityRecord, cms: TinaStudio) => boolean
/** Override behaviour for finding a record by its id. Default behaviour is to match an `_id` property. */
find?: (id: string, record: EntityRecord) => boolean
/** Instructions for previewing this entity. Optional. */
preview?: Preview | ((record: EntityRecord, cms: TinaStudio) => Promise<Preview>
}
You can add entities to the CMS config when creating the CMS:
const homepage = require('./content/homepage.json')
const cms = new TinaStudio({
entities: [
{
name: "homepage",
type: "single",
include: homepage
}
]
})
Or you can add them on-the-fly:
cms.api.github.getFile("./homepage.json")
.then(data => {
cms.entities.add({
name: "homepage",
type: "single",
include: homepage
}))
})
.catch(error => console.error(error))
Using Entities
Entities can be queried from the CMS after adding them:
const postEntity = cms.entities.find('post');
Which returns the following:
type EntityState = {
__type: "entity";
name: string;
/** Indicates the type of entity */
type: "single" | "list" | "hierarchy";
/** The base form template */
template: FormOptions<EntityShape>;
/** Returns all records */
all: () => Type extends "single" ? Promise<EntityRecord> :
/** Find a specific record by id */
find: (id: string) => Promise<EntityRecord | undefined>
/** Fetch the preview for a given record */
preview?: (id: string) => void
}
You can request all records for an entity:
const postEntity = cms.entities.find('post');
const posts = postEntity.all()
.then(posts => console.log(posts));
You can also requst a specific record by id:
const postEntity = cms.entities.find('post');
const post = postEntitiy.find('example-id')
.then(post => console.log(post));
Records also have their form template and a prepopulated form with their values:
const postEntity = cms.entities.find('post');
const post = postEntitiy.find('example-id')
.then(post => {
const { _template, _form } = post;
const customForm = new Form({
..._template,
label: "Custom Post Form",
onChange: (values) => console.log(values)
})
cms.plugins.add(_form)
cms.plugins.add(customForm)
});
Routing
Entities can be browsed by query param when routing is set to true
in the cms.mount
arguments.
- A single entity can be loaded by name:
/admin?entity=homepage
- Multiple entities can be loaded by name:
/admin?entities=header,homepage,footer
- For entities that represent lists or hierarchies, and additional param is requiered matching the entities name, equal to the unqiue ID of the record:
/admin?entities=post&post=example-id
This is a very rough example currently. Some considerations for a production use case:
- Only one preview should be loaded at a time; we need to handle when two entities with preview logic are select
- Perhaps "first entity wins" or throw an error?
- The CMS config does not support async logic for including entity records; this API should be revised.
Previews
Create an integration with the TinaCMS to enable a preview frame to display loading messages while previews are being fetched, display new previews when they are ready, and display errors to editors when fetching fails with the information developers will need to debug.
Entity Previews
Preview configuration can be added to entity configurations:
cms.entities.add({
name: "homepage",
type: "single",
include: homepage,
preview: (record, cms) => {
const src = `http://example.com/${record.slug}`;
return {
src
}
}
})
And then enabled in code:
const homepageEntity = cms.entities.find("homepage")
homepageEntity.preview()
Remote Rendering
For tools that only support build-time rendering of pages, you can get the URL for the page hosted elsewhere and render that page in the preview frame each time a form is saved:
import { createPreview, STUDIO_PREVIEW_LOADING } from 'tinacms-studio';
const useMyPreview = (recordId: string) = createPreview(cms, async () => {
try {
const src = `http://example.com/${recordId}`;
cms.events.dispatch({
type: STUDIO_PREVIEW_LOADING,
message: "Fetching preview..."
})
return {
src
}
} catch (error) {
return {
error
}
}
})
Server-side Rendering
For tools that only support server-side rendering of pages, you can get the HTML for the page and render that HTML in the preview frame each time a form is saved:
Fetch remote HTML for a record and render it in the iframe:
import { createPreview, STUDIO_PREVIEW_LOADING } from 'tinacms-studio';
const useMyPreview = createPreview(cms, async () => {
try {
const src = `http://example.com/api/${recordId}`;
cms.events.dispatch({
type: STUDIO_PREVIEW_LOADING,
message: "Fetching preview..."
})
const res = await fetch(src);
const html = await res.text();
return {
html
}
} catch (error) {
return {
error
}
}
})
Miscellenaeous
Due to time constraints, we did not have time to alter the existing TinaCMS UI (sidebar, toolbar) to support this experience. Due to this, we rapidly prototyped a new UI with a terrible name currently of fullscreen
.
This UI provides a split-pane experience closer to the ForestryCMS UX, which allowed us to rapidly prototype outcomes.
We suggest the feedback from usability testing is explored to decide how to integrate this functionality and develop a better TinaCMS UI.
export type FullscreenOptions {
overlay: boolean; // Render as a floating overlay instead of a fullscreen SPA
toggle: boolean; // Render a Tina branded floating action button to enable the CMS
mode: "editor" | "studio"
}
This is configured on the CMS configuration:
const cms = new TinaCMS({
fullscreen: {
overlay: false,
toggle: true,
mode: "editor"
}
})
NOTE
The
studio
mode was intended to be a much more Forestry-like experience, with a "entity" browser and document list. We did not have time to complete this experiment.The fullscreen UI works by applying CSS styles to other CMS UIs using fuzzy CSS selectors. This is very fragile and is not production ready.
Contributing
Prerequisites
You must have NodeJS LTS installed.
Installation
Install the project dependencies by running:
npm install
Development
Start a development server by running:
npm start
Production Builds
Build a production build by running:
npm run build