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.
@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.
| 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';
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.
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:
Stage, the default
Scene<Action, SceneContent>, and Action are 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:ts
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.
validate* are hand-written,
zero-dependency checks layered on the guards: object shape, required fields
(including each action variant's, e.g. a spotlight's elementId), known
discriminants, and the scene type <-> content binding the public Scene
type 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.```ts 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('; '));
```
ValidationResult is { valid: true } | { valid: false; errors: { path; message }[] } —
it collects every issue rather than failing on the first.
validate* reports on a
document, normalize* repairs one, so producers stop carrying their own
imperative "fix up the output" pass. normalizeElement (and the
normalizeSlide / normalizeScene / normalizeStage walkers) fill the
required fields a producer may have left off, derive geometry-dependent fields
(a line's start / end, a shape's viewBox / 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 @default annotations (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 (the id is often assigned
downstream) and carry no content default. normalize* and validate* are
complementary: normalize repairs content, validate / the schema check the
full structure.
```ts 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 ```
Two version numbers live in this package and do not track each other:
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 to DSL_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_MIGRATIONS is an ordered ladder of { from, to, migrate } steps; each
step's to is the next step's from, and the last reaches DSL_VERSION.migrate(doc) reads the document's version from its dslVersion envelope
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
than DSL_VERSION is 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.
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.
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; vendored
openmaic/types/slides.ts deleted. The importer emits complete DSL Slide
objects directly (the old partial "draft slide" + post-fill step is gone).@openmaic/renderer: imports all slide types from @openmaic/dsl; vendored
types/slides.ts deleted. @openmaic/dsl is a regular dependency, kept external
in the rollup build so consumers share one copy. The public
@openmaic/renderer/types surface now re-exports the DSL types.@openmaic/importer to import types from @openmaic/dsl (vendored copy deleted).@openmaic/renderer to import types from @openmaic/dsl (vendored copy deleted).dist/schema/*.json via a devDep generator; zero-dep
validate* functions). See Runtime layer below.stage / scene / scene-content types into the DSL (the
universal skeleton now lives in stage.ts).Action playback verb set into the DSL (action.ts); the
widget interaction actions graduated into the contract once they decoupled
from widget configs, so the standard Action union now covers them too.
Scene<TAction> defaults to that union.version.ts ships the DSL_MIGRATIONS
ladder and a pure migrate runner (idempotent, forward-compatible,
fail-loud), no longer a stub. See Version & migration above.normalize.ts ships the pure
normalize* family + ELEMENT_DEFAULTS, and the static defaults ride out
on the JSON Schema as @default annotations. Producers drop their
imperative "fix up the output" passes. See Runtime layer above.@openmaic/exporter as the 4th family member.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.
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).
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
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.