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

316 lines
19 KiB
Markdown

# @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. |
```ts
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:
```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.
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.
```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.
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.
```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
```
## 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:
```ts
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
- [x] Wire `@openmaic/importer` to import types from `@openmaic/dsl` (vendored copy deleted).
- [x] Wire `@openmaic/renderer` to 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/*.json` via a devDep generator; zero-dep
`validate*` functions). See **Runtime layer** below.
- [x] Promote the `stage` / `scene` / `scene-content` types into the DSL (the
universal skeleton now lives in `stage.ts`).
- [x] 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.
- [x] 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.
- [x] 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
```ts
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:
```ts
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/`:
```bash
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.