@openmaic/dsl
v0.11.1
Published
The MAIC slide DSL: the pure, dependency-free contract (types + JSON Schema + validators + version/migration helpers) that @openmaic/renderer and @openmaic/importer both depend on.
Readme
@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). |
| asset-manifest.ts | Pure document asset enumeration: enumerateAssetManifest, AssetManifest, entry kinds and optional caller-supplied metadata enrichment. |
| slide-media-slots.ts | Canonical read-only role descriptors for every media-bearing slide slot. |
| 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';Asset manifest (0.10)
enumerateAssetManifest(document, options?) is the standard, IO-free answer to
which asset references a stage document touches. It walks stage whiteboards,
scene canvases, scene whiteboards, speech actions, and video-manifest keys. It
recognizes slide backgrounds; image, audio, and video sources; video
mediaRefs and posters; and speech audioIds.
import {
enumerateAssetManifest,
type AssetManifest,
type AssetManifestMetadata,
} from '@openmaic/dsl';
const manifest: AssetManifest = enumerateAssetManifest(document, {
metadata(ref, kind): AssetManifestMetadata | undefined {
return metadataAlreadyKnownByTheCaller(ref, kind);
},
});manifest.entries contains one entry per distinct (ref, kind) pair in
first-occurrence document order. If one ref appears in several roles, each role
gets its own entry; the first occurrence of each pair fixes its deterministic
ordering slot without losing ownership information.
Optional metadata (byteSize, mimeType, durationSeconds, voice, and
prompt) is copied from the callback once per distinct (ref, kind) pair. The enumerator
itself performs no IO and never mutates the document. The callback is an
explicit caller boundary: it may consult caller-owned state, but it must not
mutate the document.
manifest.referenceCounts counts logical owners, not raw slots. A video
element using the same ref in both src and mediaRef is one owner; its poster
is separate. Owner keys use traversal positions rather than user-controlled
scene, slide, element, or action IDs, so duplicate IDs do not collapse distinct
owners. Consumers deciding whether bytes may be replaced in place should use
these counts instead of rebuilding an ID-keyed walk.
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_VERSIONDSL_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.
Promoting interactive and pbl into the contract does not bump
DSL_VERSION: it widens accepted content without changing stored bytes or the
meaning of an existing field. This follows the additive-field precedent in
the GeneratedAgentConfig compatibility note in stage.ts; schema-pinned
consumers still need the new package version to accept documents carrying the
newly described shapes.
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
- [x] Wire
@openmaic/importerto import types from@openmaic/dsl(vendored copy deleted). - [x] Wire
@openmaic/rendererto import types from@openmaic/dsl(vendored copy deleted). - [x] 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. - [x] Promote the
stage/scene/scene-contenttypes into the DSL (the universal skeleton now lives instage.ts). - [x] 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. - [x] Promote interactive and PBL content into the persisted contract, with a minimal widget-config extension point and the PBL design/seed skeleton.
- [x] Activate the migration registry:
version.tsships theDSL_MIGRATIONSladder and a puremigraterunner (idempotent, forward-compatible, fail-loud), no longer a stub. See Version & migration above. - [x] 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 compatibility
default 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, …>. InteractiveContent
and PBLContent are contract-owned and compose into concrete scene unions through
the generic; apps may also specialize widget configs and action unions:
import type { Action, InteractiveContent, PBLContent, Scene, SceneContent, WidgetConfigBase } from '@openmaic/dsl';
interface AppWidgetConfig extends WidgetConfigBase { featurePayload?: unknown }
type AppContent = SceneContent | InteractiveContent<AppWidgetConfig> | PBLContent;
type AppScene = Scene<Action, AppContent>;The contract owns WidgetType, the minimal WidgetConfigBase,
InteractiveContent, PBLContent, and the design-time PBLProject plus its
canonical planner-seeded skeleton. Rich per-widget payloads and PBL learner
runtime overlays stay app-side. The widget actions (widget_highlight,
widget_setState, …) remain config-free playback verbs in action.ts.
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 testLicense
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.
