@openmaic/dsl
The contract keystone of the MAIC SDK family. @openmaic/dsl is pure spec — the
slide object-model types, build-time JSON Schema artifacts, pure validators / type-guards,
and version/migration helpers — with zero runtime dependencies (no React, no
pptx, no echarts).
That purity is the whole point: the renderer, the importer, and any future
package can depend on @openmaic/dsl without pulling in junk.
Dependency arrows (acyclic)
@openmaic/dsl -> (nothing)
@openmaic/renderer -> @openmaic/dsl
@openmaic/importer -> @openmaic/dsl
@openmaic/exporter -> @openmaic/dsl (reserved, future)
@openmaic/dsl is the only package everything else depends on, and it depends on
nothing.
What's in here
| Module | Contents |
|---|---|
slides.ts |
The slide object model: Slide, PPTElement and all variants, theme, background, animation, table/chart/code types, plus ElementTypes / ShapePathFormulasKeys enums. |
stage.ts |
The lesson skeleton: Stage, generic Scene<TAction, TContent>, SceneType, StageMode, Whiteboard, VideoManifest, SlideContent, QuizContent, MultiAgentConfig, GeneratedAgentConfig, plus isSlideContent / isQuizContent guards. |
action.ts |
The playback verb set: Action and all variants (spotlight, laser, speech, the Wb* whiteboard family, play_video, discussion, and the widget_* interaction actions), ActionType, the frozen ACTION_TYPES set + isActionType guard, the FIRE_AND_FORGET_ACTIONS / SLIDE_ONLY_ACTIONS / SYNC_ACTIONS category lists, plus the PercentageGeometry overlay type. |
guards.ts |
Pure discriminant type-guards (isTextElement, …) and PPT_ELEMENT_TYPES. |
validate.ts |
Pure, zero-dep structural validators — validateStage / validateScene / validateAction returning an error-collecting ValidationResult. |
normalize.ts |
Pure, zero-dep defaulters — normalizeElement / normalizeSlide / normalizeScene / normalizeStage and the canonical ELEMENT_DEFAULTS. Fills required-field defaults, derives geometry, fails loud on malformed input; the repair counterpart to validate*. normalizeSlideWith({ onInvalid: 'drop', onDropped }) builds a map-safe normalizeSlide variant so producers normalizing wild-world input (imported decks, model output) can degrade per element instead of failing the document; normalizeSlide itself stays unary (slides.map(normalizeSlide) keeps working). |
version.ts |
Serialized-contract version + migration registry: DSL_VERSION, the DSL_MIGRATIONS ladder, and the pure migrate / dslVersionOf / needsMigration runner. |
import type { Slide, PPTElement, Action } from '@openmaic/dsl';
import { isTextElement, DSL_VERSION, SYNC_ACTIONS } from '@openmaic/dsl';
Runtime layer (schema + validators + normalizers)
The contract is enforceable two ways — a zero-dependency in-process validator
and a cross-language JSON Schema — both generated from / aligned to the same
public TS types, and both honoring the zero-runtime-dependency invariant; a
third zero-dependency normalize* family repairs a document to satisfy them:
-
JSON Schema artifacts (cross-language mirror) —
Stage, the defaultScene<Action, SceneContent>, andActionare emitted as standalone JSON Schema at build time and shipped. This is the language-neutral mirror of the contract for non-TS consumers, and the place to go for exhaustive value-level (type / format) checking:import stageSchema from '@openmaic/dsl/schema/stage.schema.json' with { type: 'json' }; import sceneSchema from '@openmaic/dsl/schema/scene.schema.json' with { type: 'json' }; import actionSchema from '@openmaic/dsl/schema/action.schema.json' with { type: 'json' }; // feed to any JSON Schema validator (ajv, or a non-TS / non-JS consumer)The schema is generated from the TS types (the single source of truth) by
ts-json-schema-generator, a devDependency — it never enters the runtime dependency set. -
Pure validators (in-process boundary) —
validate*are hand-written, zero-dependency checks layered on the guards: object shape, required fields (including each action variant's, e.g. aspotlight'selementId), known discriminants, and the scenetype<->contentbinding the publicScenetype enforces. Because they add no dependency, in-process (TS / JS) producers and consumers — generators, importers, the runtime engine — can rely on them directly without shipping a schema validator. They are a structural subset of the schema (presence + discriminants; the schema additionally checks each field's value shape), and describe the same contract. Both are kept in lockstep by a test that pins the validators' per-variant required fields to the generated schema.import { validateStage, validateScene, validateAction } from '@openmaic/dsl'; const result = validateScene(input); if (!result.valid) throw new Error(result.errors.map((e) => `${e.path}: ${e.message}`).join('; '));ValidationResultis{ valid: true } | { valid: false; errors: { path; message }[] }— it collects every issue rather than failing on the first. -
Pure normalizers (repair boundary) — where
validate*reports on a document,normalize*repairs one, so producers stop carrying their own imperative "fix up the output" pass.normalizeElement(and thenormalizeSlide/normalizeScene/normalizeStagewalkers) fill the required fields a producer may have left off, derive geometry-dependent fields (a line'sstart/end, a shape'sviewBox/path), and fail loud on a present-but-wrong-typed field — returning a fully-defaulted document that then satisfies the validators. Pure and non-mutating; idempotent.The static defaults (
ELEMENT_DEFAULTS) are the single source of truth and also ride out on the JSON Schema as@defaultannotations (so non-TS consumers ship them too); a test pins the two together.normalize*owns only the producer-independent defaults — media-specific reconciliation (e.g. fitting an image box to a resolved asset's real dimensions) stays a producer concern.Scope:
normalize*owns element content (the per-variant required fields- derivable geometry). It does not fill or check the base identity /
geometry every element shares (
id,left/top/width/height/rotate) — those are producer-supplied (theidis often assigned downstream) and carry no content default.normalize*andvalidate*are complementary: normalize repairs content, validate / the schema check the full structure.
import { normalizeElement, normalizeScene, ELEMENT_DEFAULTS } from '@openmaic/dsl'; const el = normalizeElement(rawElement); // required fields filled, geometry derived const scene = normalizeScene(rawScene); // walks the slide canvas + whiteboards - derivable geometry). It does not fill or check the base identity /
geometry every element shares (
Version & migration
Two version numbers live in this package and do not track each other:
- the npm package version (
package.json) — the semver of the code/API artifact; bumps when exports or behavior change. DSL_VERSION— the version of the serialized slide contract (the on-disk document shape). It bumps only when a persisted document's shape changes, independent of package releases. (A third, finer axis —SlideContent.schemaVersion— versions the PPTist canvas inside a slide and is migrated app-side; it is orthogonal toDSL_VERSION.)
version.ts owns the document-level migration mechanism, zero-dependency and
pure like the validators:
import { DSL_VERSION, migrate, dslVersionOf, needsMigration } from '@openmaic/dsl';
const current = migrate(doc); // walks doc from its written version up to DSL_VERSION
DSL_MIGRATIONSis an ordered ladder of{ from, to, migrate }steps; each step'stois the next step'sfrom, and the last reachesDSL_VERSION.migrate(doc)reads the document's version from itsdslVersionenvelope field (absent ⇒ treated as legacy/unversioned), walks the ladder applying each pure transform, and stamps the result. It is idempotent (a current document is returned as-is), forward-compatible (a document stamped newer thanDSL_VERSIONis returned untouched, never silently downgraded), and fail-loud (a gap in the ladder throws rather than yielding a half-migrated document).
The first ladder entry is a no-op transform that stamps legacy documents up to
the current DSL_VERSION: promoting Action into the contract and adding
validators did not change any serialized shape, so the current on-disk shape
already is 0.1.0. The entry wires the pipeline end to end and gives real
documents a version to migrate forward from; the first real transform is
appended (and DSL_VERSION bumped) when the serialized shape first changes.
Which aggregate carries the dslVersion field — a whole Stage, a single Scene
row, or a bundle — is left to the store that first consumes this pipeline; the
runner only needs the envelope field.
Runtime envelope (#869)
Learner-produced runtime data (chat, quiz attempts, playback facts) is persisted
outside the document, per learner, through a RuntimeStore (@openmaic/storage).
This package owns the envelope: RuntimeSession (identity + lifecycle, keyed by
stage/learner/kind) and RuntimeRecord<TPayload> (ordered facts; the
store-assigned seq is the replay ordering key). Core-kind payload skeletons
(chat, quizAttempt) live here; payload internals are app-owned, validated at
the store boundary via injected validators (runtime.ts guards + validate.ts).
A RuntimeSession carries its own version envelope field,
runtimeDslVersion — mechanically disjoint from a document's dslVersion — and
rides its own version line: RUNTIME_DSL_VERSION and a dedicated
RUNTIME_DSL_MIGRATIONS ladder, walked by migrateRuntime (not migrate), with
runtimeDslVersionOf / needsRuntimeMigration as the runtime-line counterparts
of dslVersionOf / needsMigration.
Unlike the document line, the runtime line has no unversioned epoch. Real
pre-versioning documents exist, so migrate lifts an unstamped document from
UNVERSIONED_DSL_VERSION via its first ladder entry. Nothing legitimately
predates the runtime envelope, though — it is a brand-new contract, and the
future RuntimeStore stamps RUNTIME_DSL_VERSION at write time. So a
RuntimeSession is born stamped (runtimeDslVersion is a required field,
not optional), RUNTIME_DSL_MIGRATIONS ships empty (no legacy-lift entry;
the first real runtime shape change appends a step from the pinned
INITIAL_RUNTIME_DSL_VERSION and bumps RUNTIME_DSL_VERSION), and an unstamped
object reaching any runtime-line function (migrateRuntime,
needsRuntimeMigration, runtimeDslVersionOf) throws — it is a misrouted
legacy document or an unstamped producer write, not legacy data to lift.
(Non-objects stay exempt: they are not migratable aggregates and read as
unversioned on every line.) An empty ladder is still fully functional — a
stamped-current session early-returns as already current, and a session stamped
at an unknown older version hits the "no migration path" fail-loud.
The two lines stamp different fields, so neither ladder reads the other's
version — but disjoint fields alone are not enough: a session lacking
dslVersion would still read as unversioned to the document runner and be
lifted onto the wrong line. The cross-line guard —
enforced in the shared envelope reader, so the plain dslVersionOf /
runtimeDslVersionOf reads, the needs*Migration predicates, and the runners
all give one answer per envelope — closes this with three-case semantics: (1) own line's stamp present → migrate normally
on the own line, regardless of the other key; (2) both stamps absent → genuine
legacy data, walk the own ladder; (3) own stamp absent but the sibling line's
stamp present → throw. Case (3) is undecidable from the envelope — the
other line's aggregate misrouted here is byte-identical to this line's data
carrying a stray foreign stamp; migrating would mangle the former, returning it
unchanged would permanently orphan the latter from its own line — so it is
treated like a malformed stamp and fails loud. validateRuntimeSession
likewise rejects a session missing its runtimeDslVersion (born stamped, no
legacy epoch) and a stray dslVersion on a session at the door. The runner
mechanism (contiguous ladder, idempotent, forward-compatible, fail-loud) is
shared; only the ladder, target version, own stamp field, and the sibling field
the guard checks differ.
Status
Both consumers are now wired to @openmaic/dsl and no longer vendor their own copy
of the slide types:
@openmaic/importer: imports all slide types from@openmaic/dsl; vendoredopenmaic/types/slides.tsdeleted. The importer emits complete DSLSlideobjects directly (the old partial "draft slide" + post-fill step is gone).@openmaic/renderer: imports all slide types from@openmaic/dsl; vendoredtypes/slides.tsdeleted.@openmaic/dslis a regular dependency, kept external in the rollup build so consumers share one copy. The public@openmaic/renderer/typessurface now re-exports the DSL types.
Roadmap
- Wire
@openmaic/importerto import types from@openmaic/dsl(vendored copy deleted). - Wire
@openmaic/rendererto import types from@openmaic/dsl(vendored copy deleted). - Add the JSON Schema for the slide contract + a pure schema validator
(build-time
dist/schema/*.jsonvia a devDep generator; zero-depvalidate*functions). See Runtime layer below. - Promote the
stage/scene/scene-contenttypes into the DSL (the universal skeleton now lives instage.ts). - Bring the
Actionplayback verb set into the DSL (action.ts); the widget interaction actions graduated into the contract once they decoupled from widget configs, so the standardActionunion now covers them too.Scene<TAction>defaults to that union; PBL configs and the app's richer content kinds still plug in viaScene's generics. - Activate the migration registry:
version.tsships theDSL_MIGRATIONSladder and a puremigraterunner (idempotent, forward-compatible, fail-loud), no longer a stub. See Version & migration above. - Own element defaulting in the contract:
normalize.tsships the purenormalize*family +ELEMENT_DEFAULTS, and the static defaults ride out on the JSON Schema as@defaultannotations. Producers drop their imperative "fix up the output" passes. See Runtime layer above. - Reserve
@openmaic/exporteras the 4th family member.
Stage / Scene split
stage.ts owns the universal lesson skeleton: Stage, the discriminated
SceneContent (SlideContent | QuizContent), and a generic
interface Scene<TAction = Action, TContent extends { type: SceneType } = SlideContent | QuizContent>
TAction defaults to the contract's standard Action union (defined in
action.ts), so a scene carries playback actions out of the box; skeleton-only
consumers that reject actions opt out with Scene<never, …>. Apps widen the
content union (and, if they add their own actions, the action union) by
injecting their own types:
import type { Scene, Action } from '@openmaic/dsl';
type AppScene = Scene<Action, SlideContent | QuizContent | InteractiveContent | PBLContent>;
Widget configs (WidgetType / WidgetConfig) and PBLProjectConfig remain
out of scope here — they're faster-moving product surfaces that stay app-side
and plug in via Scene's generics. The widget actions (widget_highlight,
widget_setState, …), by contrast, are config-free playback verbs and live in
action.ts with the rest of the Action union.
Divergence reconciled (seed provenance)
The seed is the app's lib/types/slides.ts, but before this package existed the
contract had been copy-pasted into three places that drifted apart. This
package is the canonical superset: every field that existed in any copy is
kept, so consumers can adopt the DSL without losing data. Merged-in fields are
annotated @since-merge in slides.ts.
| Field | app lib/types |
renderer copy | importer copy | DSL decision |
|---|---|---|---|---|
PPTTextElement.vAlign |
— | ✓ | ✓ | kept |
PPTImageElement.softEdge |
— | ✓ | ✓ | kept |
TableCellBorder + TableCell.borders |
— | ✓ | ✓ | kept |
TableCell.padding |
— | ✓ | ✓ | kept |
TableCell.vAlign |
— | top/middle/bottom |
up/mid/down/top/middle/bottom |
canonical = top/middle/bottom; importer already normalizes its up/mid/down aliases in transformParsedToSlides |
PPTTableElement.rowHeights |
— | ✓ | ✓ | kept |
Slide.script (speaker notes) |
— | — | ✓ | kept |
Slide.viewportSize/viewportRatio/theme |
required | required | optional | canonical = required; importer now fills them at construction in transformParsedToSlides (no partial/draft stage) |
SlideData (deprecated) |
✓ | — | ✓ | kept, @deprecated |
The importer conforms to the canonical contract: it normalizes cell vAlign
aliases and emits the required Slide fields on output. The renderer consumes
the same superset (it gains access to script and the importer-origin fields it
didn't previously declare).
Build
Pure TypeScript compiled with tsc to ESM + .d.ts, then the JSON Schema
artifacts are generated into dist/schema/:
pnpm --filter @openmaic/dsl build # -> dist/ (index.js, index.d.ts, …) + dist/schema/*.json
pnpm --filter @openmaic/dsl build:schema # regenerate only dist/schema/*.json
pnpm --filter @openmaic/dsl typecheck
pnpm --filter @openmaic/dsl test
License
MIT, matching the rest of the family (@openmaic/dsl, @openmaic/importer,
@openmaic/renderer) and the OpenMAIC root, so the license policy is uniform
across the SDK.