@archduck/gst-core
v0.1.1
Published
Compositional config-driven rendering engine
Maintainers
Readme
gst-core
Config-driven rendering engine. Describe UI as plain objects -- in JavaScript, JSON, or both -- and get reactive DOM with automatic updates, dynamic properties, scoped styles, and layered configuration loading.
gst stands for Grand Schema Things -- a play on "grand scheme of things." Forms, pages, and UIs are defined as schemas (JSON configurations) rather than code. The name captures the idea that these config-driven "things" participate in a larger system of data, interaction, and workflow.
Generic by design. No opinions about forms, pages, or any domain. Domain behavior is injected through options; gst-forms is one such plugin.
Built on @vue/reactivity and gst-compose.
npm install @archduck/gst-coreQuick start
import { mount } from '@archduck/gst-core'
mount(document.getElementById('root'), {
layout: [
{ component: 'h1', innerText: 'Hello' },
{ component: 'input', name: 'email', type: 'email' },
{ component: 'p', innerText: '{{greeting}}' },
],
registries: {
functions: {
greeting(context) {
return context.record.data.email
? `You entered: ${context.record.data.email}`
: 'Type an email above'
}
}
}
}, {
record: { key: null, data: { email: '' } }
})That's a reactive UI from a plain object. The <input> is two-way bound to record.data.email. The paragraph re-renders whenever the email changes, because greeting reads from the reactive record. No JSX, no templates, no build step required for the config itself.
The config object
Everything converges to a single shape before rendering:
{
meta: {},
layout: [],
registries: { fields, buttons, components, functions, optionSets },
styles: null,
actions: {}
}How you build this object is up to you.
Write it directly in JS when you want full control and type checking:
const form = {
layout: [
{ component: 'h1', innerText: 'Hello' },
{ component: 'input', name: 'email' },
],
registries: {
fields: {
email: { component: 'input', type: 'email', label: 'Email' }
},
functions: {
greeting(context) { return `Hi, ${context.record.data.email}` }
}
}
}
mount(document.getElementById('root'), form)Load it from JSON files when the config needs to live outside code:
import { loadJsonConfig } from '@archduck/gst-core'
const form = await loadJsonConfig('./UserView', { patches: ['uchealth'], schema })
mount(document.getElementById('root'), form)Mix both -- load structure from JSON, define functions in JS:
const form = await loadJsonConfig('./UserView', { schema })
form.registries.functions = {
...form.registries.functions,
customValidation(context) { /* ... */ }
}
mount(document.getElementById('root'), form)Dynamic properties
Any property can be a static value or a {{functionName}} reference. Functions receive (context, event) with this bound to an element-specific stage object. They re-evaluate automatically when reactive dependencies change.
{
show: '{{isAdmin}}',
label: '{{getLabel}}',
disabled: '{{isLocked}}',
innerText: '{{summary}}'
}There's no special list of "properties that can be dynamic." If you can set it statically, you can compute it with a function.
show conditionally renders an element. This works on fields, groups, buttons -- anything in the layout.
Pass arguments with {{functionName:arg}}:
{
show: '{{hasRole:editor}}',
className: '{{getStepClass:3}}'
}The arg is always a string. Parse it in the function if you need another type.
Layout
Layout is an array of groups. Groups contain rows; rows contain field names (looked up in registries.fields) or inline element definitions:
layout: [
{
title: 'Contact Info',
rows: [
['firstName', 'lastName'], // side by side
['email'], // full width
]
},
{
title: 'Preferences',
show: '{{hasAccount}}',
rows: [['theme', 'language']]
}
]Bare arrays are sugar for { rows: [...] }. String items in rows are registry lookups; object items render inline.
Registries and spread
Registries are named definition collections. Definitions inherit from each other using gst-compose spread syntax:
registries: {
fields: {
email: { component: 'input', type: 'email', placeholder: 'Email' },
workEmail: { '...': 'email', placeholder: 'Work email' },
personalEmail: { '...': 'email', placeholder: 'Personal email' },
}
}workEmail spreads everything from email, then overrides placeholder. Cross-registry references work too: "...": "fields.email" from inside a different registry.
Config loading
Load config from a directory of JSON/JS files. Files are discovered by naming convention and merged using gst-compose:
const form = await loadJsonConfig('./UserView', { schema })
// With variant overlays (each variant stacks on top)
const form = await loadJsonConfig('./UserView', { patches: ['uchealth', 'admin'], schema })File naming: property[-variant].{json|js}. A form directory might contain:
UserView/
fields.json # base field definitions
fields-uchealth.json # uchealth variant (merges with base)
layout.json # layout structure
functions.js # custom functions (JS, not JSON)
styles.json # scoped stylesThe loader finds all files, resolves variants in order, applies gst-compose spread semantics, and returns the unified config object.
LiveConfig
Patch configuration at runtime without reloading:
import { LiveConfig } from '@archduck/gst-core'
const liveConfig = new LiveConfig({ schema })
await liveConfig.load('./UserView')
liveConfig.patch('fields', { email: { required: true } })
const form = liveConfig.get()Patches cascade through dependencies. If workEmail spreads from email, patching email re-resolves workEmail too.
Scoped styles
styles: {
scoped: true,
rules: {
'&': { maxWidth: '48em', marginInline: 'auto' },
'& input': { padding: '0.5em', border: '1px solid #ccc' },
}
}& refers to the mount container. Styles are written in JS object syntax (camelCase properties), injected as a <style> element, and cleaned up on unmount. Multiple mount instances on the same page don't collide.
Extending gst-core
Custom components
Register renderers in the component registry:
registries: {
components: {
StarRating: {
_adapter: {
mount(container, { def, store }) {
// create DOM, wire events, return { unmount() {} }
}
}
}
}
}Components are referenced by name in definitions: { component: 'StarRating', max: 5 }.
Define your own domain vocabulary
createTLPSchema lets you declare what top-level properties exist, which are registries, and how they depend on each other:
import { createTLPSchema } from '@archduck/gst-core'
const schema = createTLPSchema({
functions: { registry: true, dependencies: [] },
widgets: { registry: true, dependencies: ['functions'] },
dataSources: { registry: true, dependencies: [] },
layout: { dependencies: ['widgets', 'dataSources'], default: [] },
settings: { dependencies: [] },
})The dependencies array drives resolution order via topological sort. Config loading, variant layering, spread syntax, and cross-references all work with your vocabulary. gst-forms defines its own schema (fields, buttons, actions, hooks); you could define one for a dashboard engine, page builder, or anything else.
Inject domain behavior
mount() is a generic renderer. It turns config into reactive DOM but has no opinion about what that DOM means. Domain-specific behavior goes through options:
mount(container, config, {
record: { key: null, data: {} },
components: {},
initialState: {},
effects: [(store) => effect(() => { /* ... */ })],
setupActions: (store, options) => { /* ... */ },
onMount: (store, controller) => {},
onUnmount: (store) => {},
onHide: (def, store) => {},
})gst-forms injects dirty tracking, field errors, action hooks, and confirmation dialogs through this interface. A different plugin could inject drag-and-drop, real-time collaboration, or analytics.
API reference
mount(container, config, options?) -> controller
Render config into a DOM container. Returns a controller:
controller.update(record)-- update record data reactivelycontroller.executeAction(name, extra)-- trigger an actioncontroller.unmount()-- clean up and remove from DOMcontroller.store-- the reactive store (read-only access)
createTLPSchema(declarations) -> schema
Build a schema from top-level property declarations. Returns an object with resolutionOrder, dependencies, registryMapping, validRegistries, and getDefault(property).
loadJsonConfig(path, options?) -> Promise<config>
Load and merge config files from a namespace directory. Options include schema (required), patches (variant array), scope, registries, functions.
LiveConfig
Stateful config manager with runtime patching.
new LiveConfig({ schema })liveConfig.load(path, options?)-- load from filesliveConfig.from(config, options?)-- initialize from existing configliveConfig.get()-- get resolved config (cached)liveConfig.patch(property, patch)-- merge patch, re-resolve dependentsliveConfig.set(property, value)-- replace property, re-resolve dependents
Config merging utilities
mergeConfigs(...configs)-- deep merge with array spread supportmergeRegistries(...registries)-- merge registry objectsapplyMap(config, map)-- apply string replacement map (i18n)mergeMaps(...maps)-- merge translation maps
Reactivity (re-exported from @vue/reactivity)
reactive, effect, computed, ref, toRaw, stop, pauseTracking, enableTracking
Style processing
processStyles(styles, scopeId)-- convert JS style objects to scoped CSSinjectStyles(css, id)/removeStyles(id)-- manage<style>elementsgenerateScopeId()-- generate unique scope ID
