Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00
..

@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:

  1. JSON Schema artifacts (cross-language mirror)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:

    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.

  2. 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. 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.

    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.

  3. 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 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.
    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
    

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 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.

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; 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.

Roadmap

  • Wire @openmaic/importer to import types from @openmaic/dsl (vendored copy deleted).
  • Wire @openmaic/renderer to import types from @openmaic/dsl (vendored copy deleted).
  • Add the JSON Schema for the slide contract + a pure schema validator (build-time dist/schema/*.json via a devDep generator; zero-dep validate* functions). See Runtime layer below.
  • Promote the stage / scene / scene-content types into the DSL (the universal skeleton now lives in stage.ts).
  • Bring the 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; PBL configs and the app's richer content kinds still plug in via Scene's generics.
  • Activate the migration registry: 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.
  • Own element defaulting in the contract: 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.
  • Reserve @openmaic/exporter as 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.