diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a23bac..6d412de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ ### Added +- Added [`docs/how-opf-works.md`](./docs/how-opf-works.md), a conceptual introduction to the format: the document model, the three content shapes, layouts-as-hints, narrative beats, catalog resolution, and the validation philosophy. +- Added [`docs/design-resolution.md`](./docs/design-resolution.md), an explicit design precedence algorithm (slide design → deck design → resolved theme → engine defaults) with worked examples. +- Added a `warnings` array to `ValidationResult`. The validator now warns — never errors — on unknown bare catalog ids (`narrative`, `design.theme`, `design.colorScheme`, `design.fontScheme`, chart `type`) and on broken catalog cross-links in audience, purpose, and tone records (`recommendedNarratives`, `recommendedTones`). Documents that declare matching inline `catalogs..records[]` or a custom `catalogs..source` are exempt. Warnings never affect `valid` or `assertValid`. +- Added [`spec/README.md`](./spec/README.md) orienting readers to the spec directory layout, including what the optional `spec/openapi.yaml` reference contract is for. +- Bundled example decks and catalog cross-links are now checked for unknown catalog ids in the package test suite. +- Added [`examples/technical/full-feature-tour.opf.json`](./examples/technical/full-feature-tour.opf.json), a single fixture exercising every major schema surface: intent metadata, organizations and speakers, narrative beat overrides, design with slide-level overrides, assets, inline catalog records, all ten content payload kinds, blocks, regions, hidden slides, and extensions. +- Expanded `docs/how-opf-works.md` with an anatomy diagram, the region-grid diagram, a catalog-resolution flow diagram, and runnable examples for every content shape, plus a complete small deck. Added the precedence-stack and base-plus-overrides diagrams to `docs/design-resolution.md` and the region-grid cheat sheet to `docs/content-payloads.md`. +- Presentation-shaped JSON examples embedded in the shipped docs are now validated in the package test suite, so documentation examples cannot drift from the schema. +- Added span-composition diagrams (sidebar + main, headline band + body, and their combination) to the region docs in `docs/how-opf-works.md` and `docs/content-payloads.md`. +- Added a "Start in three steps" section to the README and a root `llms.txt` index so human and agent adopters both get a direct path from problem to first validated deck. - Shipped every `.opf.json` deck under `examples/` inside the npm package and exposed them via `@openpresentation/opf/examples` (`examples`, `galleries`, `exampleCategories`, `getExample`, `getGallery`, `getExamplesByGallery`, `getExamplesByCategory`). Each example deck is validated against the presentation schema at build time. - Shipped the top-level `docs/*.md` reference pages inside the npm package and exposed them via `@openpresentation/opf/docs` (`docs`, `getDoc`). Subdirectories like `docs/migrations` and `docs/plans` are intentionally excluded. - Shipped the upstream `README.md` markdown at the pinned release version via `@openpresentation/opf/repo-readme` so consumer sites can mirror it without a network fetch. diff --git a/README.md b/README.md index 0abf73b..4781144 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,14 @@ That's the shift that lets LLMs actually *author* decks. When the format stops f And they don't start from a blank canvas. [pptx.gallery](https://pptx.gallery) is the human-browsable reference for OPF catalog presets: layouts, themes, color schemes, font schemes, chart types, narratives, audiences, purposes, tones, languages, and social platforms. +## Start in three steps + +1. **Install the format package.** `npm install @openpresentation/opf`. +2. **Author and validate a deck.** Write a `*.opf.json` file — start from [`docs/how-opf-works.md`](./docs/how-opf-works.md) or copy [`examples/technical/full-feature-tour.opf.json`](./examples/technical/full-feature-tour.opf.json) — and run `validatePresentation` on it. +3. **Build on it.** Browse presets at [pptx.gallery](https://pptx.gallery), pin the schemas in your pipeline, and track the [toolkit roadmap](#toolkit-roadmap) for the render and convert libraries. + +Your deck lives in git from the first commit. Nothing in these steps calls a hosted service, and nothing ever will — that boundary is the point. + ## JavaScript and TypeScript The canonical JavaScript/TypeScript package is published at [`packages/javascript`](./packages/javascript) as [`@openpresentation/opf`](https://www.npmjs.com/package/@openpresentation/opf). The schema is pre-stable (0.x — expect breaking changes between minor versions until 1.0). Its responsibility is local and format-level only: @@ -54,7 +62,7 @@ The OPF format package is shipping first. The planned toolkit lives outside this These repos provide OSS primitives only. Downstream applications own hosting, auth, storage, collaboration, queues, previews, analytics, support, and workflow UX. -### Expected Usage +## Usage Install from npm: @@ -89,7 +97,8 @@ const deck: Presentation = { }; const result = validatePresentation(deck); -console.log(result.valid); +console.log(result.valid); // schema correctness +console.log(result.warnings); // advisory issues, e.g. unknown catalog ids console.log(audiences.length, purposes.length, tones.length); ``` @@ -125,6 +134,8 @@ node packages/cli/dist/index.js validate path/to/deck.opf.json | Path | Contents | |---|---| | [`spec/schemas/opf.schema.json`](./spec/schemas/opf.schema.json) | Canonical JSON Schema for top-level OPF `Presentation` documents. | +| [`docs/how-opf-works.md`](./docs/how-opf-works.md) | Conceptual introduction: the document model, content shapes, catalog resolution, and the validation philosophy. Start here. | +| [`docs/design-resolution.md`](./docs/design-resolution.md) | The design precedence algorithm (slide design → deck design → resolved theme → engine defaults) with worked examples. | | [`docs/schema-reference.md`](./docs/schema-reference.md) | Author-facing reference for top-level OPF fields and every presentation schema `$defs` object/type. | | [`docs/catalog-schema-reference.md`](./docs/catalog-schema-reference.md) | Author-facing reference for every companion catalog schema. | | [`docs/content-payloads.md`](./docs/content-payloads.md) | Author-facing notes for slide and region content payloads, including chart and table object shapes. | diff --git a/docs/content-payloads.md b/docs/content-payloads.md index 98e00b9..cc36977 100644 --- a/docs/content-payloads.md +++ b/docs/content-payloads.md @@ -210,7 +210,50 @@ Timeline-specific fields are grouped under `timeline`. An array value is shortha ## Regions -The same payload objects work inside regions: +Region keys address a 3×3 grid of rows (`top`, `middle`, `bottom`) and columns (`left`, `center`, `right`): + +``` + left center right + +--------------------+--------------------+--------------------+ + top | top:left | top:center | top:right | + +--------------------+--------------------+--------------------+ + middle | middle:left | middle:center | middle:right | + +--------------------+--------------------+--------------------+ + bottom | bottom:left | bottom:center | bottom:right | + +--------------------+--------------------+--------------------+ +``` + +- A bare column key (`left`) spans all three rows; a bare row key (`top`) spans all three columns. +- `+` spans adjacent rows or columns: `center+right`, `top+middle`. +- `row:column` combines the two: `top:left`, `middle+bottom:center+right`. +- Keys on one slide must not overlap, and regions cannot be mixed with root payload fields. + +Spans compose into common slide shapes: + +``` + "left" + "center+right" "top" + "middle+bottom" + (sidebar + main) (headline band + body) + +----------+------------------+ +-------------------------------+ + | | | | top | + | | | +-------------------------------+ + | left | center+right | | | + | | | | middle+bottom | + | | | | | + +----------+------------------+ +-------------------------------+ + + "top" + "middle+bottom:left" + "middle+bottom:center+right" + (headline band, then sidebar + main) + +---------------------------------------------+ + | top | + +---------------+-----------------------------+ + | | | + | middle+bottom | middle+bottom:center+right | + | :left | | + | | | + +---------------+-----------------------------+ +``` + +The same payload objects work inside regions — here, the sidebar-plus-main shape: ```json { diff --git a/docs/design-resolution.md b/docs/design-resolution.md new file mode 100644 index 0000000..e3910f9 --- /dev/null +++ b/docs/design-resolution.md @@ -0,0 +1,130 @@ +# Design Resolution + +How an engine decides the effective design for any given slide. The schema spreads these rules across field descriptions; this page states them once, as an algorithm. + +## Precedence + +For every design field independently, the most specific source wins: + +``` + wins +--------------------------------------------------------+ + ^ | 1. slide design slides[i].design.* | + | +--------------------------------------------------------+ + | | 2. deck design design.* on the presentation root | + | +--------------------------------------------------------+ + | | 3. resolved theme colorScheme, fontScheme, | + | | background, dimensions from the | + | | theme record | + | +--------------------------------------------------------+ + loses | 4. engine defaults e.g. spec/reference/ | + v | engine-defaults.json | + +--------------------------------------------------------+ +``` + +1. **Slide design** — `slides[].design.*` +2. **Deck design** — `design.*` on the presentation root +3. **Resolved theme** — defaults carried by the theme record (`colorScheme`, `fontScheme`, `background`, `dimensions`) +4. **Engine defaults** — engine configuration such as [`spec/reference/engine-defaults.json`](../spec/reference/engine-defaults.json) + +Resolution is **per field**, not per object. A slide that sets only `design.contentAlignment` inherits everything else from the deck design; a deck that sets only `design.colorScheme` keeps the theme's font scheme and background. + +Two field-level rules complete the picture: + +- **Base-plus-overrides within one object.** Wherever a reference object carries an `id` (`Theme`, `ColorScheme`, `FontScheme`), the `id` resolves a catalog record as the base and sibling fields override the resolved record per key. The string shorthand (`"colorScheme": "cool-horizon"`) is equivalent to setting only `id`. + + ``` + "colorScheme": { "id": "cool-horizon", "accent1": "#0F4C81" } + + catalog record "cool-horizon" sibling fields on the object + accent1: "#2874A6" <-- replaced -- accent1: "#0F4C81" + accent2: "#1B4F72" <-- kept + light1: "#FFFFFF" <-- kept + | + v + effective scheme: accent1 from the override, everything else + from the record + ``` +- **Explicit suppression.** `watermark`, `header`, and `footer` accept `false` to switch off an inherited value — distinct from omitting the field, which inherits. + +Catalog lookups inside this chain follow the standard resolution order (inline `catalogs..records[]` → `catalogs..source` → default catalog); see [`how-opf-works.md`](./how-opf-works.md). + +## Worked example 1: color scheme through every level + +```json +{ + "design": { + "theme": "classic", + "colorScheme": "forest-green" + }, + "slides": [ + { "title": "Inherits the deck" }, + { + "title": "Slide override", + "design": { + "colorScheme": { "id": "cool-horizon", "accent1": "#0F4C81" } + } + } + ] +} +``` + +- Slide 1: the `classic` theme record supplies its own default color scheme, but the deck design sets `colorScheme` explicitly, so `forest-green` wins (level 2 beats level 3). Fonts, background, and dimensions still come from `classic`. +- Slide 2: slide design beats deck design (level 1 beats level 2). The `cool-horizon` record resolves as the base, then `accent1` is replaced by `#0F4C81`. All other `cool-horizon` slots survive. + +There is no ambiguity between "override" and "reference": every scheme value *is* a reference, and any sibling fields on the same object are overrides applied after the reference resolves. + +## Worked example 2: backgrounds and suppression + +```json +{ + "design": { + "theme": "dark", + "background": "light1", + "footer": { + "left": { "text": "Acme Corp" }, + "right": { "slideNumber": true } + } + }, + "slides": [ + { "title": "Light slide in a dark theme" }, + { + "title": "Section divider", + "design": { + "background": { + "type": "gradient", + "gradient": { + "angle": 90, + "stops": [ + { "color": "#0B1B2B", "position": 0 }, + { "color": "#123A5F", "position": 1 } + ] + } + }, + "footer": false + } + } + ] +} +``` + +- Slide 1: the deck-level `background: "light1"` overrides the `dark` theme's default background. `light1` is a theme slot — it resolves through the effective color scheme, which itself resolved through the chain above. +- Slide 2: the gradient replaces the deck background for this slide only, and `footer: false` suppresses the inherited footer rather than inheriting or replacing it. + +## Worked example 3: font scheme models + +```json +{ + "design": { + "fontScheme": { + "id": "aptos", + "code": { "family": "JetBrains Mono" } + } + } +} +``` + +The `aptos` record supplies the OOXML pair (`major`/`minor`). The `code` role is an OPF-specific addition with no OOXML slot, so it layers on top without disturbing the pair. When serializing to PowerPoint, engines write `major`/`minor` to `majorFont`/`minorFont` and map abstract roles (`heading`, `body`) onto those slots; roles like `accent` and `code` are renderer concerns. The same slot-versus-role split applies to color schemes: OOXML slots (`accent1`–`accent6`, `dark1/2`, `light1/2`) round-trip directly, abstract roles (`primary`, `text`, `surface`, …) are mapped onto slots by the engine. + +## What is *not* part of this chain + +Content payloads carry no design controls in v1 — `position`, `fontSize`, per-payload colors and the like were deliberately kept out while the content model stabilizes (see [`content-item-design-overrides.md`](./content-item-design-overrides.md)). The design system above, plus layout hints (`titleAlignment`, `contentBox`, `chartPrimary`, …), is the entire styling surface of an OPF document. diff --git a/docs/examples.md b/docs/examples.md index f7cbad5..e19e9a1 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -8,6 +8,8 @@ The `examples/` directory has three layers: ## Technical Fixtures +Start with [`examples/technical/full-feature-tour.opf.json`](../examples/technical/full-feature-tour.opf.json), one deck that touches every major schema surface — useful as a copy-paste source and as a single fixture for renderer smoke tests. + Use `examples/technical/` when you want a small file that exercises a specific schema surface: - content payloads, rich text, blocks, charts, tables, media, metrics, quotes, and timelines diff --git a/docs/how-opf-works.md b/docs/how-opf-works.md new file mode 100644 index 0000000..458471f --- /dev/null +++ b/docs/how-opf-works.md @@ -0,0 +1,322 @@ +# How OPF Works + +An OPF document is one JSON file that answers three questions about a presentation: + +- **What does it say?** — `slides`, with content payloads and assets. +- **Who is it for and why?** — `audience`, `purpose`, `tone`, `language`, and `narrative`. +- **What should it look like?** — `design`, resolved through themes, color schemes, and font schemes. + +The document records intent; an engine (a renderer, exporter, or editor) turns that intent into pixels or `.pptx` output. OPF deliberately stops at the format boundary: it never embeds OOXML, layout geometry, or renderer-specific state. You — or your agent — own the story, the data, and the ask; the format's job is to keep all of that readable, diffable, and out of `` tags. + +## Anatomy of a document + +``` +Presentation +├── identity ...... name, description, organization, speaker, author +├── intent ........ audience, purpose, tone, language, narrative, takeaway, duration +├── content ....... slides[] +│ ├── title / subtitle / tag / notes / section / beat / layout +│ └── one content shape: +│ root payload (a single content kind) +│ blocks[] (ordered payloads, placement inferred) +│ region keys (3x3 placement grid) +├── design ........ theme, colorScheme, fontScheme, background, logo, header, footer +├── assets ........ named media sources, referenced as "asset:" +└── catalogs ...... per-kind overrides: inline records and/or custom sources +``` + +Only `slides` is required. The smallest valid document: + +```json +{ + "name": "Minimal OPF Deck", + "slides": [ + { "title": "Minimal OPF Deck" }, + { "title": "Next Steps", "text": "Use this as a starting point." } + ] +} +``` + +Everything else in the format is optional and additive. + +## Slides and content + +A slide carries its content in one of three shapes. Pick the loosest shape that says what you mean — engines handle placement. + +**1. Root payload** — one content kind directly on the slide. The kind is inferred from the field present (`text`, `items`, `chart`, `table`, `image`, `video`, `code`, `metric`, `quote`, `timeline`); see [`content-payloads.md`](./content-payloads.md) for the full table. + +```json +{ + "title": "Operating Metric", + "metric": { "value": "42%", "label": "Review cycle reduction", "trend": "up" } +} +``` + +Multiple kinds at the slide root (with no explicit `type`, `blocks`, or regions) are shorthand for the equivalent `blocks`: + +```json +{ + "title": "Habitat", + "text": "Jaguars are strongly associated with water and dense cover.", + "items": ["Rainforests and flooded wetlands", "Large defended territories"] +} +``` + +**2. `blocks`** — an ordered list of payloads when a slide has several pieces of content but placement should stay renderer-inferred: + +```json +{ + "title": "Customer Feedback", + "blocks": [ + { "table": { "columns": ["Theme", "Mentions"], "rows": [["Speed", 42], ["Ease of use", 31]] } }, + { "quote": { "text": "The new workflow cut review time in half.", "attribution": "Operations Lead" } } + ] +} +``` + +**3. Promoted region keys** — a 3×3 placement grid when position matters: + +``` + left center right + +--------------------+--------------------+--------------------+ + top | top:left | top:center | top:right | + +--------------------+--------------------+--------------------+ + middle | middle:left | middle:center | middle:right | + +--------------------+--------------------+--------------------+ + bottom | bottom:left | bottom:center | bottom:right | + +--------------------+--------------------+--------------------+ + + A bare column key ("left") spans all three rows. + A bare row key ("top") spans all three columns. + Keys span neighbors with "+" and intersect rows with columns via ":". +``` + +The spans compose into the slide shapes you actually want: + +``` + "left" + "center+right" "top" + "middle+bottom" + (sidebar + main) (headline band + body) + +----------+------------------+ +-------------------------------+ + | | | | top | + | | | +-------------------------------+ + | left | center+right | | | + | | | | middle+bottom | + | | | | | + +----------+------------------+ +-------------------------------+ + + "top" + "middle+bottom:left" + "middle+bottom:center+right" + (headline band, then sidebar + main) + +---------------------------------------------+ + | top | + +---------------+-----------------------------+ + | | | + | middle+bottom | middle+bottom:center+right | + | :left | | + | | | + +---------------+-----------------------------+ +``` + +That last shape in JSON: + +```json +{ + "title": "Adoption Doubled", + "top": { "text": "Adoption doubled while support load stayed flat." }, + "middle+bottom:left": { "metric": { "value": "2.1x", "label": "Adoption" } }, + "middle+bottom:center+right": { + "chart": { "type": "line", "data": { "columns": ["Month", "Teams"], "rows": [["Jan", 12], ["Feb", 18]] } } + } +} +``` + +And the two-column shape from the grid above: + +```json +{ + "title": "Operating Snapshot", + "left": { "table": { "columns": ["Metric", "Value"], "rows": [["Revenue", "$4.2M"]] } }, + "center+right": { "chart": { "type": "line", "data": { "columns": ["Month", "Revenue"], "rows": [["Jan", 3.4]] } } } +} +``` + +Region keys on one slide must not overlap, and regions cannot be mixed with a root payload. Slide-level strings `title`, `subtitle`, and `tag` sit alongside whichever content shape you use, and render into the matching placeholders of the resolved layout. + +## Layouts are hints, not contracts + +`Slide.layout` optionally references a record in the `layouts` catalog. The layout's placeholders describe what the layout *exposes* (a title slot, chart regions, image treatment) — they do not constrain what the slide may contain. This loose coupling is intentional: + +- A slide may use any region keys or payloads regardless of its declared layout. Validators do not error on a slide/layout mismatch. +- When `layout` is omitted, engines infer one from the slide's payload or region keys. +- Free-form layout names that don't resolve through any catalog fall through to engine-defined layouts. + +The principle, used throughout OPF: **slides are the source of truth**. Layouts, narratives, and design records guide rendering; they never invalidate content. + +## Narrative is intent, not structure + +`narrative` declares the deck's story arc. It resolves to a record in the `narratives` catalog (e.g. `"classic-story"`, `"pitch-deck"`), each of which defines ordered **beats** — labeled segments of the arc such as `hook`, `problem`, `evidence`, `ask` — with optional slide-blueprint hints (`slideType`, `layoutHint`, `instructions`, `thoughtCues`). + +Slides opt into beats via `Slide.beat`. Nothing forces them to: validators warn on drift (orphan slides, unused beats) but never error. + +```json +{ + "name": "Schema Pitch", + "narrative": { + "id": "technical-proof", + "name": "Technical Proof", + "beats": [ + { "id": "contract", "name": "Contract", "slideType": "text", "instructions": "State what stays stable." }, + { "id": "evidence", "name": "Evidence", "slideType": "chart" }, + { "id": "adoption", "name": "Adoption", "slideType": "list" } + ] + }, + "slides": [ + { "beat": "contract", "title": "The Contract", "text": "Beats describe intent without constraining slides." }, + { "beat": ["evidence", "adoption"], "title": "Proof And Ask", "items": ["One slide may cover several beats."] } + ] +} +``` + +Object form supports overrides: `{ "id": "classic-story", "beats": [...] }` merges inline beats into the catalog record by beat `id`. An object whose `id` matches no record — like `technical-proof` above — is a fully custom inline narrative. Deck-level concerns that aren't part of the storyline (`audience`, `tone`, `takeaway`, `duration`) live as siblings on the presentation root, not inside the narrative. + +## Catalog references and how they resolve + +Most reusable values in OPF are references into **catalogs**: named collections of records, each identified by a kebab-case `id`. The referencing fields are `narrative`, `language`, `tone`, `audience`, `purpose`, `design.theme`, `design.colorScheme`, `design.fontScheme`, `Slide.layout`, `Chart.type`, and the platform keys in `socials`. + +Every reference resolves through the same chain, first match wins: + +``` + "design": { "colorScheme": "cool-horizon" } + | + v + 1. catalogs.colorSchemes.records[] inline records in this document + | miss + v + 2. catalogs.colorSchemes.source custom registry declared in this document + | miss + v + 3. default catalog https://www.pptx.gallery/color-schemes + | miss (bundled in spec/catalogs/ and in + v the @openpresentation/opf package) + validation warning — never an error — and an engine fallback +``` + +When a reference is omitted entirely, engines fall back to their own defaults (see [`spec/reference/engine-defaults.json`](../spec/reference/engine-defaults.json) for a reference example — that file is engine configuration, not part of the document contract). + +Three reference forms are accepted wherever a catalog reference is allowed: + +- **Bare id** for the common case: `"narrative": "classic-story"`. +- **Object form** for catalog-backed overrides: `{ "id": "cool-horizon", "accent1": "#0F4C81" }` resolves the record as a base, then inline fields win per key. +- **URL or `pkg:` reference**, which skips the catalog lookup and resolves directly. + +A document can carry its own records or point at a private registry, which also silences unknown-id warnings for that kind: + +```json +{ + "name": "Branded Deck", + "design": { "colorScheme": "acme-brand" }, + "catalogs": { + "colorSchemes": { + "records": [{ "id": "acme-brand", "accent1": "#0F4C81", "light1": "#FFFFFF", "dark1": "#0B1B2B" }] + }, + "narratives": { "source": "https://catalogs.example.com/narratives" } + }, + "slides": [{ "title": "Branded Deck" }] +} +``` + +## Design in one paragraph + +`design` selects a `theme` (which bundles default color scheme, font scheme, background, and dimensions) and may override any of those directly; `Slide.design` overrides the deck design per slide. More specific always wins, field by field. Color schemes and font schemes each support two mixable models — OOXML slots/pairs that round-trip to PowerPoint, and abstract roles (`primary`, `heading`, `code`, …) that engines map onto slots. The full precedence chain with worked examples is in [`design-resolution.md`](./design-resolution.md). + +## Assets + +Binary content lives in the top-level `assets` registry, keyed by id. Content payloads and design fields reference entries with `asset:` strings; asset `src` values accept HTTPS URLs, data URIs, and paths resolved against the OPF file location. + +## A complete small deck + +Everything above, together — intent metadata, a catalog-backed narrative with beats, design, an organization and speaker, an asset-backed chart, regions, notes, and sections: + +```json +{ + "$schema": "https://openpresentation.org/schema/opf/v1", + "name": "Q3 Business Review", + "description": "Quarterly review for the executive team.", + "audience": "executives", + "purpose": "decide", + "tone": "formal", + "language": "en-US", + "narrative": "qbr", + "takeaway": "Approve the expanded rollout budget.", + "duration": 20, + "organization": { + "id": "acme", + "name": "Acme Corp", + "domain": "acme.com", + "socials": { "linkedin": "acme" } + }, + "speaker": { "id": "alice", "name": "Alice Chen", "title": "VP Operations", "organizationId": "acme" }, + "design": { + "theme": "classic", + "colorScheme": "forest-green", + "footer": { "left": { "organization": true }, "right": { "slideNumber": true } } + }, + "assets": { + "adoption-csv": { "src": "./data/adoption.csv", "alt": "Monthly adoption data" } + }, + "slides": [ + { + "layout": "title", + "beat": "objectives", + "title": "Q3 Business Review", + "subtitle": "Operations — October 2025" + }, + { + "beat": "performance-headline", + "title": "Adoption Doubled", + "left": { "metric": { "value": "2.1x", "label": "Quarter-over-quarter adoption", "trend": "up" } }, + "center+right": { + "chart": { "type": "line", "data": { "src": "asset:adoption-csv", "columns": ["Month", "Active Teams"] } } + }, + "notes": "Pause here; this is the slide the decision hangs on." + }, + { + "beat": "risks", + "section": "Decision", + "title": "What Could Go Wrong", + "items": [ + "Capacity: two regions are at 85% utilization.", + { + "text": "Churn risk in the legacy tier.", + "description": "Mitigation: migration incentives ship in November." + } + ] + }, + { + "beat": "asks", + "title": "The Ask", + "text": "Approve $1.2M to expand the rollout to all regions in Q4." + } + ] +} +``` + +The beat ids (`objectives`, `performance-headline`, `risks`, `asks`) come from the `qbr` narrative record; the theme, color scheme, chart type, and layout all resolve through the bundled catalogs. For a fixture that exercises the full surface in one file, see [`examples/technical/full-feature-tour.opf.json`](../examples/technical/full-feature-tour.opf.json). + +## Validation philosophy + +Two layers, with a deliberate split: + +- **Schema errors** for structural problems: wrong types, overlapping region keys, payloads mixing incompatible content kinds, a region payload missing concrete content. +- **Warnings** for advisory drift: unknown catalog ids, narrative/slide mismatches. These never make a document invalid. + +`validatePresentation` from `@openpresentation/opf` applies both layers locally. + +## Where to go next + +- [`schema-reference.md`](./schema-reference.md) — every field of every object in the presentation schema. +- [`catalog-schema-reference.md`](./catalog-schema-reference.md) — every field of every catalog record schema. +- [`content-payloads.md`](./content-payloads.md) — payload shapes and inference rules with examples. +- [`design-resolution.md`](./design-resolution.md) — the design precedence algorithm. +- [`examples.md`](./examples.md) — guide to the example decks under `examples/`. + +Then write a deck, commit it, revise it, and read the diff. A two-line diff for a two-word change is the whole argument for the format. diff --git a/examples/technical/README.md b/examples/technical/README.md index 07e961c..7eff80f 100644 --- a/examples/technical/README.md +++ b/examples/technical/README.md @@ -4,6 +4,8 @@ This folder contains focused fixtures that isolate OPF schema behavior. They are Use these examples when testing validators, renderers, catalog resolution, region semantics, asset source handling, design overrides, metadata forms, and individual content payloads. +Start with [`full-feature-tour.opf.json`](./full-feature-tour.opf.json): a single deck that exercises every major schema surface — intent metadata, organizations and speakers, narrative beat overrides, design with slide-level overrides, assets, inline catalog records, all ten content payload kinds, blocks, regions, hidden slides, and extensions. + ## Coverage Areas - Root slide payloads and explicit content `type` values. diff --git a/examples/technical/full-feature-tour.opf.json b/examples/technical/full-feature-tour.opf.json new file mode 100644 index 0000000..b52b9a7 --- /dev/null +++ b/examples/technical/full-feature-tour.opf.json @@ -0,0 +1,249 @@ +{ + "$schema": "https://openpresentation.org/schema/opf/v1", + "name": "Full Feature Tour", + "description": "Technical fixture exercising every major surface of the OPF presentation schema in one document: intent metadata, organizations and speakers, narrative beats, design with slide overrides, assets, inline catalog records, all content payload kinds, blocks, regions, and extensions.", + "filename": "full-feature-tour.pptx", + "author": "OPF Maintainers", + "audience": [ + "executives", + { "id": "engineering-team", "attentionBudgetMinutes": 30 } + ], + "purpose": "decide", + "tone": "formal", + "language": { "id": "english-us" }, + "takeaway": "One OPF file can carry intent, content, design, and provenance together.", + "duration": 25, + "tags": ["fixture", "schema-tour"], + "organization": [ + { + "id": "acme", + "name": "Acme Corp", + "legalName": "Acme Corporation, Inc.", + "domain": "acme.com", + "tagline": "Operations you can see through", + "role": "primary", + "logo": "asset:acme-logo", + "socials": { "linkedin": "acme", "github": "acme" } + }, + { "id": "northwind", "name": "Northwind Analytics", "role": "partner" } + ], + "speaker": [ + { + "id": "alice", + "name": "Alice Chen", + "title": "VP Operations", + "organizationId": "acme", + "bio": "Alice runs global operations and owns the rollout program.", + "photo": "asset:alice-headshot", + "socials": { "x": "@alicechen" } + }, + { "id": "ben", "name": "Ben Ortiz", "title": "Principal Engineer", "organizationId": "acme" } + ], + "narrative": { + "id": "qbr", + "beats": [ + { + "id": "asks", + "name": "The Ask", + "instructions": "Close on the single budget decision; everything else is appendix." + } + ] + }, + "design": { + "theme": { "id": "classic", "background": "light2" }, + "colorScheme": { "id": "forest-green", "accent1": "#0F4C81" }, + "fontScheme": "aptos", + "dimensions": "widescreen", + "titleAlignment": "left", + "contentAlignment": "left", + "watermark": { "src": "asset:acme-logo", "opacity": 0.05 }, + "footer": { + "left": { "organization": true }, + "center": { "section": true }, + "right": { "slideNumber": true } + } + }, + "assets": { + "acme-logo": { "src": "./assets/acme-logo.svg", "alt": "Acme Corp logo" }, + "alice-headshot": { "src": "./assets/alice.jpg", "alt": "Alice Chen headshot" }, + "rollout-photo": { + "src": "https://cdn.example.com/rollout-floor.jpg", + "alt": "Distribution floor during the pilot rollout", + "title": "Pilot rollout floor" + }, + "adoption-csv": { + "src": "./data/adoption.csv", + "mediaType": "text/csv", + "description": "Monthly active teams by region." + }, + "walkthrough-clip": { + "src": "https://cdn.example.com/walkthrough.mp4", + "mediaType": "video/mp4", + "alt": "Ninety-second product walkthrough" + } + }, + "catalogs": { + "colorSchemes": { + "records": [ + { + "id": "acme-night", + "accent1": "#4FC3F7", + "light1": "#FFFFFF", + "dark1": "#0B1B2B", + "background": "#0B1B2B", + "text": "#E8F1F8" + } + ] + } + }, + "slides": [ + { + "layout": "title", + "beat": "objectives", + "title": "Full Feature Tour", + "subtitle": "Q3 Business Review — Acme Corp", + "tag": "FIXTURE", + "notes": "Cover slide: title layout, beat reference, tag badge, footer furniture." + }, + { + "layout": "text-1x", + "beat": "recap", + "title": "Rich Text", + "text": [ + "Text payloads accept plain strings or runs: ", + { "text": "bold", "bold": true }, + ", ", + { "text": "italic", "italic": true }, + ", and ", + { "text": "linked", "href": "https://openpresentation.org" }, + " segments." + ] + }, + { + "beat": "performance-headline", + "section": "Performance", + "title": "Adoption Doubled", + "left": { + "metric": { + "value": "2.1x", + "label": "Quarter-over-quarter adoption", + "delta": "+8 regions", + "trend": "up" + } + }, + "center+right": { + "chart": { + "type": "line", + "data": { "src": "asset:adoption-csv", "columns": ["Month", "Active Teams"] } + } + }, + "notes": "Regions plus asset-backed chart data." + }, + { + "layout": "chart-1x", + "beat": "performance-detail", + "section": "Performance", + "title": "Revenue By Region", + "chart": { + "type": "column", + "data": { + "columns": ["Region", "Q2", "Q3"], + "rows": [ + ["Americas", 4.2, 5.1], + ["EMEA", 3.1, 3.9], + ["APAC", 1.8, 2.6] + ] + } + } + }, + { + "beat": "wins", + "section": "Performance", + "title": "Proof Points", + "blocks": [ + { + "table": { + "columns": ["Customer", "Cycle Time", "Change"], + "rows": [ + ["Globex", "11 days", "-48%"], + ["Initech", "9 days", "-52%"] + ] + } + }, + { + "quote": { + "text": "The new workflow made exceptions visible before they became escalations.", + "attribution": "VP Operations, Globex", + "source": "Customer interview, September" + } + } + ] + }, + { + "layout": "list-2x", + "beat": ["challenges", "risks"], + "section": "Performance", + "title": "Challenges And Risks", + "items": [ + "Capacity: two regions are at 85% utilization.", + { + "text": "Churn risk in the legacy tier.", + "description": "Mitigation: migration incentives ship in November.", + "level": 0 + }, + { "text": "Vendor SLA renegotiation slips to Q1.", "level": 1 } + ] + }, + { + "beat": "insights", + "section": "Plan", + "title": "How The Pipeline Works", + "code": { + "source": "for region in regions:\n deck = build_review(region)\n validate(deck)\n publish(deck)", + "language": "python", + "filename": "pipeline.py" + } + }, + { + "beat": "sequencing", + "section": "Plan", + "title": "Rollout Timeline", + "timeline": { + "name": "Regional Rollout", + "events": [ + { "when": "Q4 2025", "what": "Expand pilot", "description": "Add EMEA and APAC." }, + { "when": "Q1 2026", "what": "General availability" } + ] + } + }, + { + "beat": "priorities", + "section": "Plan", + "title": "On The Floor", + "type": "image", + "image": "asset:rollout-photo", + "design": { + "background": { "type": "theme", "slot": "dark2" }, + "colorScheme": "acme-night", + "imageFill": "crop" + }, + "notes": "Slide-level design override resolving an inline catalog record (acme-night)." + }, + { + "section": "Plan", + "title": "Walkthrough", + "video": "asset:walkthrough-clip", + "hidden": true, + "notes": "Hidden backup slide; played only if the live demo fails." + }, + { + "beat": "asks", + "section": "Decision", + "title": "The Ask", + "text": "Approve $1.2M to expand the rollout to all regions in Q4." + } + ], + "extensions": { + "com.example.workflow": { "reviewState": "approved", "ticket": "OPS-1142" } + } +} diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..99ec5c8 --- /dev/null +++ b/llms.txt @@ -0,0 +1,25 @@ +# Open Presentation Format (OPF) + +> Portable, human-readable JSON document format for slide decks. Plain JSON instead of zipped OOXML, so humans can diff decks, git can track them, and LLMs can author them. MIT licensed. npm package: @openpresentation/opf. + +Authoring rule of thumb: a presentation is `{ "name": "...", "slides": [...] }` and only `slides` is required. Slides carry content as a root payload (text, items, chart, table, image, video, code, metric, quote, timeline), as `blocks`, or in 3x3 region keys (`left`, `center+right`, `top:left`, ...). Reusable values (narrative, theme, colorScheme, fontScheme, layout, chart type, audience, purpose, tone, language) are kebab-case ids resolved against catalogs. Unknown ids warn, never error. Validate with `validatePresentation` from @openpresentation/opf. + +## Docs + +- [How OPF works](https://github.com/OpenPresentation/opf/blob/main/docs/how-opf-works.md): start here — document model, content shapes, catalog resolution, validation philosophy +- [Schema reference](https://github.com/OpenPresentation/opf/blob/main/docs/schema-reference.md): every field of every presentation object +- [Content payloads](https://github.com/OpenPresentation/opf/blob/main/docs/content-payloads.md): payload shapes and inference rules +- [Design resolution](https://github.com/OpenPresentation/opf/blob/main/docs/design-resolution.md): design precedence algorithm with worked examples +- [Catalog schema reference](https://github.com/OpenPresentation/opf/blob/main/docs/catalog-schema-reference.md): every field of every catalog record schema +- [Examples guide](https://github.com/OpenPresentation/opf/blob/main/docs/examples.md): map of the 125 example decks + +## Spec + +- [Presentation JSON Schema](https://github.com/OpenPresentation/opf/blob/main/spec/schemas/opf.schema.json): draft 2020-12, $id https://openpresentation.org/schema/opf/v1 +- [Catalog records](https://github.com/OpenPresentation/opf/tree/main/spec/catalogs): audiences, narratives, layouts, chart types, themes, color schemes, font schemes, tones, purposes, languages, social platforms +- [Full-feature example deck](https://github.com/OpenPresentation/opf/blob/main/examples/technical/full-feature-tour.opf.json): one file exercising every major schema surface + +## Optional + +- [pptx.gallery](https://pptx.gallery): human-browsable catalog presets +- [npm package](https://www.npmjs.com/package/@openpresentation/opf): schemas, catalogs, TypeScript types, and local validation; docs and examples are bundled in the package diff --git a/packages/javascript/README.md b/packages/javascript/README.md index 043fa62..b3d7ef1 100644 --- a/packages/javascript/README.md +++ b/packages/javascript/README.md @@ -149,6 +149,19 @@ import { repoReadme } from "@openpresentation/opf/repo-readme"; console.log(repoReadme.split("\n").slice(0, 3).join("\n")); ``` +Validation results carry `errors` (structural problems that make `valid` +false) and `warnings` (advisory issues such as unknown catalog ids in +`narrative`, `design`, or chart `type` references — these never affect +`valid`). Documents that declare matching inline `catalogs..records[]` +or a custom `catalogs..source` are exempt from unknown-id warnings for +that kind. + +```ts +const result = validatePresentation(deck); +if (!result.valid) console.error(result.errors); +for (const warning of result.warnings) console.warn(warning.path, warning.message); +``` + Validate catalog records locally: ```ts diff --git a/packages/javascript/scripts/generate.mjs b/packages/javascript/scripts/generate.mjs index 5339743..d623a12 100644 --- a/packages/javascript/scripts/generate.mjs +++ b/packages/javascript/scripts/generate.mjs @@ -93,6 +93,9 @@ function mediaTypeForSpecFile(file) { if (file.endsWith(".yaml") || file.endsWith(".yml")) { return "application/yaml"; } + if (file.endsWith(".md")) { + return "text/markdown"; + } return "application/octet-stream"; } @@ -180,6 +183,29 @@ async function generateCatalogs() { await fs.writeFile(path.join(generatedRoot, "catalogs.ts"), lines.join("\n")); } +async function generateCatalogIds() { + // Lightweight id-only view of the bundled catalogs so the validator can + // check references without pulling full catalog records into its bundle. + const lines = [generatedHeader("spec/catalogs//*.json")]; + lines.push("export const catalogIds = {"); + for (const definition of catalogDefinitions) { + const catalogDir = path.join(catalogRoot, definition.dir); + const index = await readJson(path.join(catalogDir, "index.json")); + const files = await orderedCatalogFiles(definition, catalogDir, index); + const ids = []; + for (const file of files) { + const record = await readJson(path.join(catalogDir, file)); + if (typeof record.id === "string") { + ids.push(record.id); + } + } + lines.push(` ${definition.kind}: ${asTs(ids)},`); + } + lines.push("} as const;", ""); + + await fs.writeFile(path.join(generatedRoot, "catalog-ids.ts"), lines.join("\n")); +} + async function generateSpecFiles() { const files = await collectFiles(specRoot); const entries = files.map((file) => ({ @@ -227,5 +253,6 @@ await fs.rm(generatedRoot, { recursive: true, force: true }); await fs.mkdir(generatedRoot, { recursive: true }); await generateSchemas(); await generateCatalogs(); +await generateCatalogIds(); await generateSpecFiles(); await generateTypes(); diff --git a/packages/javascript/src/validator.ts b/packages/javascript/src/validator.ts index 5f858b0..2bf31e2 100644 --- a/packages/javascript/src/validator.ts +++ b/packages/javascript/src/validator.ts @@ -2,6 +2,7 @@ import Ajv2020, { type ErrorObject, type ValidateFunction } from "ajv/dist/2020. import addFormats from "ajv-formats"; import { catalogSchemaNames, type CatalogKind } from "./catalogs.js"; +import { catalogIds } from "./generated/catalog-ids.js"; import type { JsonSchema } from "./json.js"; import { schemas, type SchemaName } from "./schemas.js"; import type { Presentation } from "./types.js"; @@ -17,6 +18,8 @@ export interface ValidationIssue { export interface ValidationResult { valid: boolean; errors: ValidationIssue[]; + /** Advisory issues such as unknown catalog ids. Warnings never affect `valid`. */ + warnings: ValidationIssue[]; schemaName?: SchemaName; catalogKind?: CatalogKind; } @@ -522,6 +525,175 @@ function validatePresentationSemantics(value: unknown): ValidationIssue[] { return issues; } +const bareIdPattern = /^[a-z0-9][a-z0-9-]*$/; + +interface CatalogReferenceContext { + document: Record; +} + +function inlineCatalogEntry(context: CatalogReferenceContext, kind: CatalogKind): Record | undefined { + const catalogsField = context.document.catalogs; + if (!isRecord(catalogsField)) { + return undefined; + } + const entry = catalogsField[kind]; + return isRecord(entry) ? entry : undefined; +} + +function unknownIdWarning( + kind: CatalogKind, + value: unknown, + path: string, + context?: CatalogReferenceContext, +): ValidationIssue | undefined { + if (typeof value !== "string" || !bareIdPattern.test(value)) { + return undefined; + } + + if (context) { + const entry = inlineCatalogEntry(context, kind); + if (entry) { + if (Array.isArray(entry.records) + && entry.records.some((record) => isRecord(record) && record.id === value)) { + return undefined; + } + if (typeof entry.source === "string") { + // A custom catalog source may define ids the bundled catalogs don't know about. + return undefined; + } + } + } + + if ((catalogIds[kind] as readonly string[]).includes(value)) { + return undefined; + } + + return semanticIssue(path, `unknown ${kind} catalog id '${value}'`, { kind, id: value }); +} + +function referenceObjectWarning( + kind: CatalogKind, + value: unknown, + path: string, + context: CatalogReferenceContext, +): ValidationIssue | undefined { + if (typeof value === "string") { + return unknownIdWarning(kind, value, path, context); + } + if (isRecord(value)) { + return unknownIdWarning(kind, value.id, pathFor(path, "id"), context); + } + return undefined; +} + +function pushIfDefined(issues: ValidationIssue[], issue: ValidationIssue | undefined): void { + if (issue) { + issues.push(issue); + } +} + +function designReferenceWarnings( + design: unknown, + path: string, + context: CatalogReferenceContext, +): ValidationIssue[] { + if (!isRecord(design)) { + return []; + } + + const issues: ValidationIssue[] = []; + pushIfDefined(issues, referenceObjectWarning("themes", design.theme, pathFor(path, "theme"), context)); + pushIfDefined(issues, referenceObjectWarning("colorSchemes", design.colorScheme, pathFor(path, "colorScheme"), context)); + pushIfDefined(issues, referenceObjectWarning("fontSchemes", design.fontScheme, pathFor(path, "fontScheme"), context)); + + if (isRecord(design.theme)) { + const themePath = pathFor(path, "theme"); + pushIfDefined(issues, referenceObjectWarning("colorSchemes", design.theme.colorScheme, pathFor(themePath, "colorScheme"), context)); + pushIfDefined(issues, referenceObjectWarning("fontSchemes", design.theme.fontScheme, pathFor(themePath, "fontScheme"), context)); + } + + return issues; +} + +function chartTypeWarnings( + payload: unknown, + path: string, + context: CatalogReferenceContext, +): ValidationIssue[] { + if (!isRecord(payload)) { + return []; + } + + const issues: ValidationIssue[] = []; + if (isRecord(payload.chart)) { + pushIfDefined(issues, unknownIdWarning("chartTypes", payload.chart.type, `${pathFor(path, "chart")}/type`, context)); + } + if (Array.isArray(payload.blocks)) { + payload.blocks.forEach((block, index) => { + issues.push(...chartTypeWarnings(block, `${pathFor(path, "blocks")}/${index}`, context)); + }); + } + return issues; +} + +function presentationReferenceWarnings(value: unknown): ValidationIssue[] { + if (!isRecord(value) || !Array.isArray(value.slides)) { + return []; + } + + const context: CatalogReferenceContext = { document: value }; + const issues: ValidationIssue[] = []; + + // String shorthand only: an inline narrative object with an unknown id is a + // legitimate fully-custom narrative, not a broken reference. + if (typeof value.narrative === "string") { + pushIfDefined(issues, unknownIdWarning("narratives", value.narrative, "/narrative", context)); + } + + issues.push(...designReferenceWarnings(value.design, "/design", context)); + + value.slides.forEach((slide, index) => { + if (!isRecord(slide)) { + return; + } + const slidePath = `/slides/${index}`; + issues.push(...designReferenceWarnings(slide.design, pathFor(slidePath, "design"), context)); + issues.push(...chartTypeWarnings(slide, slidePath, context)); + for (const key of Object.keys(slide)) { + if (promotedRegionKeySet.has(key)) { + issues.push(...chartTypeWarnings(slide[key], pathFor(slidePath, key), context)); + } + } + }); + + return issues; +} + +const catalogCrossLinkFields: Partial>> = { + audience: { recommendedNarratives: "narratives", recommendedTones: "tones" }, + purpose: { recommendedNarratives: "narratives", recommendedTones: "tones" }, + tone: { recommendedNarratives: "narratives" }, +}; + +function catalogCrossLinkWarnings(schemaName: SchemaName, value: unknown): ValidationIssue[] { + const fields = catalogCrossLinkFields[schemaName]; + if (!fields || !isRecord(value)) { + return []; + } + + const issues: ValidationIssue[] = []; + for (const [field, kind] of Object.entries(fields)) { + const links = value[field]; + if (!Array.isArray(links)) { + continue; + } + links.forEach((link, index) => { + pushIfDefined(issues, unknownIdWarning(kind, link, `${pathFor("/", field)}/${index}`)); + }); + } + return issues; +} + function validateLanguageSemantics(value: unknown): ValidationIssue[] { if (!isRecord(value)) { return []; @@ -540,16 +712,23 @@ export function validate(value: unknown, schemaOrKind: SchemaOrKind = "presentat const resolved = resolveValidator(schemaOrKind); const valid = resolved.validate(value) === true; const errors = valid ? [] : (resolved.validate.errors ?? []).map(toIssue); + const warnings: ValidationIssue[] = []; if (resolved.schemaName === "presentation") { errors.push(...validatePresentationSemantics(value)); + warnings.push(...presentationReferenceWarnings(value)); } else if (resolved.schemaName === "language") { errors.push(...validateLanguageSemantics(value)); } + if (resolved.schemaName) { + warnings.push(...catalogCrossLinkWarnings(resolved.schemaName, value)); + } + return { valid: errors.length === 0, errors, + warnings, schemaName: resolved.schemaName, catalogKind: resolved.catalogKind, }; diff --git a/packages/javascript/test/smoke.mjs b/packages/javascript/test/smoke.mjs index 7c79082..3ebbb9d 100644 --- a/packages/javascript/test/smoke.mjs +++ b/packages/javascript/test/smoke.mjs @@ -731,6 +731,20 @@ for (const entry of catalogEntries) { assert.equal(result.valid, true, `${entry.kind}: ${JSON.stringify(result.errors, null, 2)}`); } +// Cross-links inside the bundled catalogs must resolve: a bundled record that +// recommends an unknown narrative or tone id is a broken link in the spec. +for (const kind of ["audiences", "purposes", "tones"]) { + const entry = catalogEntries.find((candidate) => candidate.kind === kind); + for (const record of entry.records) { + const result = validateCatalogRecord(kind, record); + assert.equal( + result.warnings.length, + 0, + `${kind}/${record.id} has broken cross-links: ${JSON.stringify(result.warnings, null, 2)}`, + ); + } +} + const invalidLanguageResult = validateCatalogRecord("languages", { $schema: "https://openpresentation.org/schema/opf-language/v1", id: "english-uk", @@ -743,6 +757,85 @@ assert.ok( JSON.stringify(invalidLanguageResult.errors, null, 2), ); +const unknownNarrativeDoc = { + name: "Unknown Narrative", + narrative: "definitely-not-a-narrative", + slides: [{ title: "Slide Title" }], +}; +const unknownNarrativeResult = validatePresentation(unknownNarrativeDoc); +assert.equal(unknownNarrativeResult.valid, true, "unknown catalog ids must warn, never error"); +assert.ok( + unknownNarrativeResult.warnings.some( + (warning) => warning.path === "/narrative" && warning.message.includes("unknown narratives catalog id"), + ), + JSON.stringify(unknownNarrativeResult.warnings, null, 2), +); +assert.doesNotThrow(() => assertValid(unknownNarrativeDoc), "warnings must not throw in assertValid"); + +assert.equal(validatePresentation({ + name: "Known Narrative", + narrative: "classic-story", + slides: [{ title: "Slide Title" }], +}).warnings.length, 0); + +// Object form with an unknown id is a fully custom inline narrative, not a broken reference. +assert.equal(validatePresentation({ + name: "Custom Inline Narrative", + narrative: { id: "my-own-arc", beats: [{ id: "hook", name: "Hook" }] }, + slides: [{ title: "Slide Title" }], +}).warnings.length, 0); + +const unknownDesignResult = validatePresentation({ + name: "Unknown Design References", + design: { theme: "no-such-theme", colorScheme: { id: "no-such-scheme", accent1: "#112233" } }, + slides: [ + { title: "Slide Title", design: { fontScheme: "no-such-fonts" } }, + ], +}); +assert.equal(unknownDesignResult.valid, true); +assert.ok(unknownDesignResult.warnings.some((warning) => warning.path === "/design/theme")); +assert.ok(unknownDesignResult.warnings.some((warning) => warning.path === "/design/colorScheme/id")); +assert.ok(unknownDesignResult.warnings.some((warning) => warning.path === "/slides/0/design/fontScheme")); + +const unknownChartTypeResult = validatePresentation({ + name: "Unknown Chart Type", + slides: [{ + title: "Slide Title", + left: { chart: { type: "no-such-chart", data: { columns: ["A", "B"], rows: [["x", 1]] } } }, + }], +}); +assert.equal(unknownChartTypeResult.valid, true); +assert.ok( + unknownChartTypeResult.warnings.some( + (warning) => warning.path === "/slides/0/left/chart/type" && warning.message.includes("unknown chartTypes catalog id"), + ), + JSON.stringify(unknownChartTypeResult.warnings, null, 2), +); + +// Inline catalog records and custom sources legitimize ids the bundled catalogs don't know. +assert.equal(validatePresentation({ + name: "Inline Catalog Record", + design: { colorScheme: "my-brand" }, + catalogs: { colorSchemes: { records: [{ id: "my-brand", accent1: "#0F4C81" }] } }, + slides: [{ title: "Slide Title" }], +}).warnings.length, 0); + +assert.equal(validatePresentation({ + name: "Custom Catalog Source", + narrative: "internal-arc", + catalogs: { narratives: { source: "https://catalogs.example.com/narratives" } }, + slides: [{ title: "Slide Title" }], +}).warnings.length, 0); + +const audienceTemplate = audiences.find((record) => record.id === "executives"); +const brokenAudienceResult = validateCatalogRecord("audiences", { + ...audienceTemplate, + recommendedNarratives: ["no-such-narrative", "classic-story"], +}); +assert.equal(brokenAudienceResult.valid, true, "broken cross-links must warn, never error"); +assert.equal(brokenAudienceResult.warnings.length, 1, JSON.stringify(brokenAudienceResult.warnings, null, 2)); +assert.equal(brokenAudienceResult.warnings[0].path, "/recommendedNarratives/0"); + const require = createRequire(import.meta.url); const rawPresentation = require("../dist/spec/schemas/opf.schema.json"); assert.equal(rawPresentation.$id, presentation.$id); @@ -761,6 +854,11 @@ for (const example of examples) { true, `Example ${example.slug} failed validation: ${JSON.stringify(result.errors, null, 2)}`, ); + assert.equal( + result.warnings.length, + 0, + `Example ${example.slug} references unknown catalog ids: ${JSON.stringify(result.warnings, null, 2)}`, + ); } for (const gallery of galleries) { assert.ok(gallery.slug.length > 0); @@ -783,6 +881,37 @@ for (const doc of docs) { assert.ok(doc.title.length > 0, `doc ${doc.slug} missing title`); assert.ok(doc.markdown.length > 0, `doc ${doc.slug} missing markdown`); } +// Every presentation-shaped JSON example embedded in the shipped docs must +// validate cleanly — docs that teach the format cannot drift from the schema. +let validatedDocExamples = 0; +for (const doc of docs) { + const fencedBlocks = [...doc.markdown.matchAll(/```json\n([\s\S]*?)```/g)]; + for (const [, block] of fencedBlocks) { + let parsed; + try { + parsed = JSON.parse(block); + } catch { + continue; + } + if (!parsed || !Array.isArray(parsed.slides)) { + continue; + } + const result = validatePresentation(parsed); + assert.equal( + result.valid, + true, + `doc ${doc.slug} has an invalid presentation example: ${JSON.stringify(result.errors, null, 2)}`, + ); + assert.equal( + result.warnings.length, + 0, + `doc ${doc.slug} example references unknown catalog ids: ${JSON.stringify(result.warnings, null, 2)}`, + ); + validatedDocExamples += 1; + } +} +assert.ok(validatedDocExamples >= 5, `expected at least 5 presentation examples across docs, found ${validatedDocExamples}`); + const docSlugs = docs.map((doc) => doc.slug); assert.ok(docSlugs.includes("schema-reference")); assert.equal(getDoc("schema-reference")?.slug, "schema-reference"); diff --git a/spec/README.md b/spec/README.md new file mode 100644 index 0000000..73c14dc --- /dev/null +++ b/spec/README.md @@ -0,0 +1,42 @@ +# spec/ + +Canonical, package-addressable OPF spec content. Everything in this directory ships inside `@openpresentation/opf` and is importable as `@openpresentation/opf/spec/`. + +New to the format? Read [`docs/how-opf-works.md`](../docs/how-opf-works.md) first — this directory is the machine-readable half of that story. + +## Layout + +| Path | Contents | +|---|---| +| [`schemas/opf.schema.json`](./schemas/opf.schema.json) | Canonical JSON Schema for top-level OPF `Presentation` documents (`$id: https://openpresentation.org/schema/opf/v1`). | +| [`schemas/*.schema.json`](./schemas) | Companion schemas for catalog records. Each has a stable `$id` of the form `https://openpresentation.org/schema/opf-/v1` (e.g. `opf-narrative`, `opf-theme`, `opf-chart-type`). | +| [`catalogs//`](./catalogs) | Bundled catalog records, one JSON file per record plus an `index.json` per kind. These are the same records served from `https://www.pptx.gallery/`. | +| [`previews/`](./previews) | Preview metadata for catalog records (currently layout previews). | +| [`reference/engine-defaults.json`](./reference/engine-defaults.json) | Reference example of engine-side defaults. Engine configuration, not part of the OPF document contract; it has no JSON Schema. | +| [`openapi.yaml`](./openapi.yaml) | Optional reference OpenAPI 3.1 contract for downstream services that choose to expose OPF operations (validate, parse, convert, generate, render) over HTTP. OpenPresentation does not host this API; implementers can use it as a starting point for their own hosted or internal services. Local format tooling never needs it. | + +## Consuming this directory + +Validate a document and load catalog records without touching the files directly: + +```ts +import { validatePresentation, narratives } from "@openpresentation/opf"; + +const result = validatePresentation(deck); +// result.valid — schema correctness +// result.errors — structural problems +// result.warnings — advisory issues such as unknown catalog ids +``` + +Import the raw files when an engine, resolver, or non-JavaScript toolchain needs them: + +```ts +import presentationSchema from "@openpresentation/opf/spec/schemas/opf.schema.json" with { type: "json" }; +import qbr from "@openpresentation/opf/spec/catalogs/narratives/qbr.json" with { type: "json" }; +``` + +The same paths work for any validator in any language: point a JSON Schema draft 2020-12 implementation at `schemas/opf.schema.json` and validate `.opf.json` files against it. Catalog records validate against their kind's companion schema. + +## Stability + +The presentation schema `$id` is pinned to `/v1` and the package is pre-stable (0.x): expect breaking changes between minor versions until 1.0, tracked in [`CHANGELOG.md`](../CHANGELOG.md) with migration notes under [`docs/migrations/`](../docs/migrations). diff --git a/spec/catalogs/audiences/engineering-team.json b/spec/catalogs/audiences/engineering-team.json index 3d0b271..30b7346 100644 --- a/spec/catalogs/audiences/engineering-team.json +++ b/spec/catalogs/audiences/engineering-team.json @@ -9,7 +9,7 @@ "decisionPower": "advisory", "attentionBudgetMinutes": 45, "recommendedNarratives": [ - "engineering-update", + "status-update", "challenge-resolution", "innovation", "weekly-progress" diff --git a/spec/catalogs/tones/casual.json b/spec/catalogs/tones/casual.json index d6d8543..94a6345 100644 --- a/spec/catalogs/tones/casual.json +++ b/spec/catalogs/tones/casual.json @@ -25,7 +25,7 @@ "weekly-progress", "status-update", "company-intro", - "all-hands" + "focus" ], "tags": ["internal", "warm", "conversational"] } diff --git a/spec/catalogs/tones/conversational.json b/spec/catalogs/tones/conversational.json index 041243a..fb25047 100644 --- a/spec/catalogs/tones/conversational.json +++ b/spec/catalogs/tones/conversational.json @@ -22,7 +22,7 @@ "If you take one thing from this section, take this." ], "recommendedNarratives": [ - "all-hands", + "focus", "company-intro", "weekly-progress", "performance-review" diff --git a/spec/catalogs/tones/technical.json b/spec/catalogs/tones/technical.json index 231c363..82ea262 100644 --- a/spec/catalogs/tones/technical.json +++ b/spec/catalogs/tones/technical.json @@ -25,7 +25,7 @@ "challenge-resolution", "innovation", "conference-talk", - "engineering-update" + "status-update" ], "tags": ["technical", "engineering", "research"] }