# File formats Source: https://docs.asakiri.com/build/file-formats The JSON for collections, records, assets, lessons, parts, compositions, and exercises. Canonical format, version 1. Every example below is copied from the `japanese-starter` fixture, which is validated against the published [JSON Schemas](https://github.com/AsakiriLingo/asakiri-studio/tree/main/schemas/asakiri-course/v1) on every build. ## The envelope **Every** file in a course starts with the same two keys, not just the manifest: ```json theme={null} { "format": "asakiri-course", "formatVersion": 1 } ``` They are omitted from the examples below for brevity. A reader should check them on each file it opens, because a file copied between courses carries its own version. ## Versioning and migration `formatVersion` is a monotonic integer, raised only for a breaking change. | You meet | Do | | --------------------- | ---------------------------------------------------------------------------------- | | A version you support | Read it. | | A **lower** version | Upgrade it in memory by applying each migration step above that version in order. | | A **higher** version | Refuse the file with a clear message naming both versions. Do not guess. | | No envelope at all | Treat it as version 0, from before the format was stamped, and migrate it forward. | Studio upgrades in memory on read and only writes the upgraded shape when that file is next saved, so opening a course never rewrites it. These changes do **not** raise the version, and readers must tolerate them: * a new optional key on an existing file type; * a new exercise type, composition block type, or part content kind. ## Unknown content A reader that meets a `content.kind`, exercise `type`, or block `type` it does not know must **keep the file verbatim** rather than fail the course. Studio parses such a part as: ```ts theme={null} { kind: "unknown", declaredKind: string, declaredType: string | null, raw: unknown } ``` The lesson still opens, the part keeps its position in the order, the editor shows a non-editable placeholder, and the body file is left untouched on save. That is what lets an older build open a course authored by a newer one without destroying data. A **known** type carrying a malformed payload is still an error. Tolerance covers new vocabulary, not corruption. ## Localized text Any authored string may be a plain string in the course's `defaultLocale`, or a map of locale tag to string: ```json theme={null} "title": "Practice 猫" "title": { "en": "Practice 猫", "ja": "猫の練習" } ``` This applies to the project title, subtitle, and description; unit titles; lesson, part, and part-content titles; exercise instructions; and the `text` of a literal text fragment. Taught content is not localized this way: it lives in records, where a language is a field and list items carry their own `locale`. Resolve with an exact tag match first, then the base language (`pt-BR` to `pt`), then any available entry. A writer that edits one locale must preserve the others. ## Contributors A contributor may hold several roles. `roles` is the list; `role` repeats the first entry so readers written against version 1 keep working. ```json theme={null} "contributors": [ { "id": "c1", "name": "Alok", "role": "author", "roles": ["author", "voice"], "links": [] } ] ``` The course's own `version` and `releasedOn` (as `YYYY-MM-DD`) also live on `project`. Both are authored metadata and have nothing to do with `formatVersion`, which describes the file schema. ## Collection A collection defines fields and lists its record files (paths relative to the collection file). ```json theme={null} { "id": "collection_vocabulary", "name": "Vocabulary", "description": "Reusable vocabulary records shown as a table in the Content area.", "fields": [ { "id": "field_japanese", "name": "Japanese", "kind": "text", "cardinality": "one", "locale": "ja", "required": true }, { "id": "field_english", "name": "English", "kind": "text", "cardinality": "one", "locale": "en", "required": true }, { "id": "field_pronunciations", "name": "Pronunciations", "kind": "asset", "assetKind": "audio", "cardinality": "many", "required": false }, { "id": "field_alternate_answers", "name": "Alternate answers", "kind": "text", "cardinality": "many", "locale": "en", "required": false } ], "recordFiles": ["../records/cat.json", "../records/dog.json", "../records/bird.json"] } ``` See [Content records](/data-structure/content-records) for the field type reference. ## Record One record per file. A `many` field stores a `list`; each list item carries a stable `id`. ```json theme={null} { "id": "record_cat", "collectionId": "collection_vocabulary", "fields": { "field_japanese": { "kind": "text", "value": "猫" }, "field_english": { "kind": "text", "value": "cat" }, "field_pronunciations": { "kind": "list", "items": [ { "id": "pronunciation_cat_ja", "kind": "asset", "label": "Japanese (primary speaker)", "locale": "ja", "assetId": "asset_cat_ja_audio" } ] }, "field_alternate_answers": { "kind": "list", "items": [ { "id": "answer_cat_with_article", "kind": "text", "value": "a cat" }, { "id": "answer_cat_plural", "kind": "text", "value": "cats" } ] } }, "presentations": [ { "id": "presentation_cat_primary", "primaryFieldId": "field_japanese", "columns": [{ "fieldId": "field_english", "visible": true }] } ] } ``` ## Asset Each asset lives in its own directory with an `asset.json` and (when ready) the binary file. ```json theme={null} { "id": "asset_cat_ja_audio", "kind": "audio", "label": "猫 (Japanese pronunciation)", "availability": "placeholder", "file": null, "expectedFile": "original.wav", "mimeType": "audio/wav", "metadata": { "locale": "ja", "speaker": "Primary speaker", "transcript": "猫" } } ``` A ready asset also records the digest of its stored file: ```json theme={null} { "id": "asset_cat_image", "kind": "image", "availability": "ready", "file": "original.svg", "mimeType": "image/svg+xml", "sha256": "f1f53575998a8c612b6d974dd1b6cc6d65bf8e0a55f92df6040e570009071cca", "byteSize": 1124 } ``` * `availability` is `ready` or `placeholder`. A placeholder has `file: null` and names the file it expects in `expectedFile`. * `sha256` and `byteSize` describe the file **as stored in the project**, not the file the author picked. Studio strips EXIF from images on import, which changes the bytes, so the digest is computed after the copy. Use it for integrity checks and as a cache key. * When ready, `file` names the binary in the same directory (for example `original.svg`). Resolve it relative to the asset's folder. * Never embed binary data or absolute paths. Content references an asset by `id` only. See [Media](/data-structure/media) for the type and supported formats. ## Lesson and parts A lesson is an ordered list of parts. Each part points at one file and declares its kind. ```json theme={null} { "id": "lesson_meet_neko", "title": "Meet 猫", "parts": [ { "id": "part_intro", "title": "Meet your first word", "content": { "kind": "tiptap", "file": "parts/intro/document.json" } }, { "id": "part_in_media", "title": "Cat in words, sound, and image", "content": { "kind": "composition", "file": "parts/in-media/composition.json" } }, { "id": "part_choose", "title": "Choose the matching word", "content": { "kind": "exercise", "file": "parts/choose/exercise.json" } } ] } ``` | `content.kind` | File | Format | | -------------- | ------------------ | ----------------------------- | | `tiptap` | `document.json` | [Rich text](/build/rich-text) | | `composition` | `composition.json` | Below | | `exercise` | `exercise.json` | Below | ## Composition A rich-media composition is a list of blocks. Each block has a `type`, a [binding](/data-structure/bindings), and presentation hints. Observed block types are `content-card`, `media`, and `callout`. ```json theme={null} { "blocks": [ { "id": "block_cat_card", "type": "content-card", "binding": { "kind": "record", "recordId": "record_cat" }, "presentation": { "view": "vocabulary-card", "showFieldIds": ["field_japanese", "field_english"] } }, { "id": "block_cat_image", "type": "media", "binding": { "kind": "item", "recordId": "record_cat", "fieldId": "field_images", "itemId": "image_cat_primary" }, "presentation": { "fit": "contain", "caption": "猫 (cat)" } }, { "id": "block_detached_note", "type": "callout", "binding": { "kind": "literal", "value": { "type": "text", "text": "This note is detached and will not change with the record." } }, "presentation": { "tone": "note" } } ] } ``` ## Exercise The on-disk exercise JSON mirrors the TypeScript interfaces in [Exercises](/data-structure/exercises): a common envelope (`id`, `type`, `instruction`, `prompt`, `evaluation`) plus a type-specific body. Two examples: ```json multiple-choice theme={null} { "id": "exercise_identify_cat", "type": "multiple-choice", "instruction": "Choose the English word that matches the image and audio.", "prompt": [ { "id": "prompt_cat_image", "role": "visual", "binding": { "kind": "item", "recordId": "record_cat", "fieldId": "field_images", "itemId": "image_cat_primary" } }, { "id": "prompt_cat_audio", "role": "audio", "binding": { "kind": "item", "recordId": "record_cat", "fieldId": "field_pronunciations", "itemId": "pronunciation_cat_ja" } } ], "options": [ { "id": "option_cat", "body": [ { "id": "option_cat_text", "role": "primary", "binding": { "kind": "field", "recordId": "record_cat", "fieldId": "field_english" } } ] }, { "id": "option_dog", "body": [ { "id": "option_dog_text", "role": "primary", "binding": { "kind": "field", "recordId": "record_dog", "fieldId": "field_english" } } ] } ], "evaluation": { "kind": "selected-options", "correctOptionIds": ["option_cat"] } } ``` ```json word-order theme={null} { "id": "exercise_cat_word_order", "type": "word-order", "instruction": "Put the tiles in order to say \"This is a cat.\"", "prompt": [ { "id": "p", "role": "primary", "binding": { "kind": "literal", "value": { "type": "text", "text": "Build: This is a cat." } } } ], "tokens": [ { "id": "token_kore", "body": [ { "id": "t1", "role": "primary", "binding": { "kind": "literal", "value": { "type": "text", "text": "これ" } } } ] }, { "id": "token_neko", "body": [ { "id": "t2", "role": "primary", "binding": { "kind": "field", "recordId": "record_cat", "fieldId": "field_japanese" } } ] } ], "distractors": [ { "id": "distractor_inu", "body": [ { "id": "d1", "role": "primary", "binding": { "kind": "field", "recordId": "record_dog", "fieldId": "field_japanese" } } ] } ], "evaluation": { "kind": "ordered-tokens", "correctOrder": ["token_kore", "token_neko"] } } ``` Correctness always refers to the stable IDs the exercise defines (`option`, `token`, `pair`, and `blank` IDs) or to accepted-value bindings, never to displayed text. See [Resolving and rendering](/build/resolving-and-rendering#grading) for how each `evaluation` kind is graded. # Build on Asakiri Source: https://docs.asakiri.com/build/overview What you need to read a course (learner app) or read and write one (creator app). The on-disk format is canonical: `"format": "asakiri-course"`, `"formatVersion": 1`. Validate against the published [JSON Schemas](https://github.com/AsakiriLingo/asakiri-studio/tree/main/schemas/asakiri-course/v1) rather than reimplementing the parser, and read [Versioning and migration](/build/file-formats#versioning-and-migration) before you ship. These pages describe the concrete files and contracts, not just the domain model. If you have not read the [Course data structure](/data-structure/overview) section yet, start there for the concepts. This section covers the wire format. ## Two targets Load a course, resolve bindings, render fragments, play media, and grade responses. Everything a reader does, plus editing files, enforcing the reference lifecycle, and validating before write. ## What to read, in order The [directory tree and manifest](/build/project-layout): how a course project is arranged and how `project.json` points at everything. The [JSON for each entity](/build/file-formats): collections, records, assets, lessons, parts, compositions, and exercises. The [Tiptap document format](/build/rich-text), including the custom nodes that embed reusable content and media. The [learner-app contract](/build/resolving-and-rendering): turning bindings into values and grading each response. [How to assemble a reader or a writer](/build/reader-and-writer), plus versioning and what is still unspecified. ## What the format guarantees you * **Every file is versioned.** `format` and `formatVersion` appear on every file, not just the manifest, so a file copied between courses still says what it is. * **Identity is stable and separate from order.** Reordering a course rewrites lists of IDs and never changes the IDs themselves, so progress keyed on them survives. * **Unknown content is preserved, not rejected.** A type you do not recognize is kept verbatim, so an older reader can open a newer course. * **Answers never reference display text.** Grading refers to authored IDs or accepted-value bindings. ## Source of truth Every page here tracks real code in the Studio repo: the `examples/courses/japanese-starter` fixture, `src/core/course` (types, `resolve-binding.ts`, `parse-course.ts`), and `src/platform/project-reading` and `project-writing` (the directory layout). When the code changes, these pages should change with it. # Project layout Source: https://docs.asakiri.com/build/project-layout The directory tree of a course project and the project.json manifest. Canonical format, version 1. Validate against the published [JSON Schemas](https://github.com/AsakiriLingo/asakiri-studio/tree/main/schemas/asakiri-course/v1) rather than reimplementing the parser. A course is a single project folder. One `project.json` manifest at the root names every collection, asset, and lesson by a project-relative path, and defines the outline. ## Directory tree ``` japanese-starter/ ├─ project.json manifest: metadata, file lists, outline ├─ content/ │ ├─ collections/ │ │ ├─ vocabulary.json a collection (fields + record file list) │ │ └─ hiragana.json │ └─ records/ │ ├─ cat.json one record per file │ ├─ dog.json │ └─ hiragana/ │ ├─ a.json │ └─ ha.json ├─ media/ │ └─ assets/ │ └─ cat-image/ │ ├─ asset.json asset metadata │ └─ original.svg the binary file └─ lessons/ └─ meet-neko/ ├─ lesson.json lesson: ordered parts, each pointing at a file └─ parts/ ├─ intro/document.json a Tiptap rich-text part ├─ in-media/composition.json a rich-media part └─ choose/exercise.json an exercise part ``` Paths in `project.json` are relative to the project root. Paths inside a collection file (its `recordFiles`) are relative to that collection file, so `content/collections/vocabulary.json` references records with `"../records/cat.json"`. ## project.json ```json theme={null} { "format": "asakiri-course", "formatVersion": 1, "project": { "id": "course_japanese_starter", "title": "Japanese Starter", "description": "A small fixture showing reusable content across lessons and every exercise type.", "defaultLocale": "en", "learningLocales": ["ja"] }, "collections": ["content/collections/vocabulary.json", "content/collections/hiragana.json"], "assets": [ "media/assets/cat-image/asset.json", "media/assets/cat-ja-audio/asset.json" ], "lessons": [ "lessons/meet-neko/lesson.json", "lessons/practice-neko/lesson.json" ], "outline": [ { "id": "section_getting_started", "title": "Getting started", "lessonIds": ["lesson_meet_neko", "lesson_practice_neko"] } ] } ``` | Field | Meaning | | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `format`, `formatVersion` | Always `"asakiri-course"` and an integer version. Every file in the project carries them, not just the manifest. | | `project` | Course metadata. `defaultLocale` and `learningLocales` drive locale handling. | | `collections`, `assets`, `lessons` | Project-relative paths to each entity file. | | `outline` | Ordered sections, each with a `title` and an ordered `lessonIds` list. The outline is the course structure; the `lessons` list is just where to load them from. | The parsed in-memory `CourseProject` (see [Reader and writer](/build/reader-and-writer)) carries more optional fields than the fixture writes, such as `level`, `license`, `contributors`, `funding`, `sponsors`, and `coverAssetId`. The fixture keeps the manifest minimal; a real course may populate more. Read defensively and treat extra fields as optional. ## Loading order 1. Read and parse `project.json`. Reject an unknown `format`; refuse a `formatVersion` higher than you support, and upgrade one lower than yours (see [File formats](/build/file-formats#versioning-and-migration)). 2. Load each collection file, then its `recordFiles`. 3. Load each asset file. 4. Load each lesson file, then each part file it references. 5. Build the in-memory course and a binding resolver (see [Resolving and rendering](/build/resolving-and-rendering)). # Reader and writer Source: https://docs.asakiri.com/build/reader-and-writer Assembling a course reader or writer, with versioning and what is still unspecified. Canonical format, version 1. A writer carries more responsibility than a reader: it must preserve keys it does not understand, keep unknown parts untouched, and stamp the format envelope on everything it writes. ## The parsed course A reader loads the files into one in-memory `Course` value, then hands it to a [binding resolver](/build/resolving-and-rendering). ```ts theme={null} interface Course { readonly project: CourseProject; readonly collections: readonly Collection[]; readonly records: readonly ContentRecord[]; readonly assets: readonly Asset[]; readonly lessons: readonly Lesson[]; readonly outline: readonly OutlineSection[]; } ``` ## Building a reader 1. Read `project.json`. Reject an unknown `format`; branch on `formatVersion`. 2. Load collections and their `recordFiles`, then assets, then lessons and their part files (see [Project layout](/build/project-layout#loading-order)). 3. Build the `Course` and a resolver. 4. Render lessons part by part; grade exercises with their `evaluation`. The reader is expected to be resilient. The current `ProjectReader` contract, for example, returns an explicit failure rather than throwing: * Manifest missing: fails as `unavailable`. * A referenced file is malformed JSON: fails as `unavailable`. * Manifest with no collections: succeeds with an empty list. Mirror that spirit. A single bad file should degrade to a clear error or a `missing` resolution, not a crash. ## Building a writer A creator app reads, edits, and writes back. Two things make writes safe: **Write back to the same file.** Parsing retains the source path of every entity, so an edit updates the exact file it came from rather than rewriting the whole project. ```ts theme={null} interface CourseSources { readonly project: string; readonly collections: Readonly>; readonly records: Readonly>; readonly assets: Readonly>; readonly lessons: Readonly>; readonly parts: Readonly>; // keyed by `${lessonId}::${partId}` } ``` **Respect the [reference lifecycle](/data-structure/reference-lifecycle).** References are live. Do not silently delete a referenced record, field, item, or asset; require the author to replace, detach, or knowingly break the reference. Detaching writes an inline `literal` snapshot. Detaching presentation from a record does not copy binary media. Validate before writing: check that every binding resolves, that required fields are present, and that referenced records and assets exist. The Studio repo validates its fixture in CI with `pnpm check:example`; do the same against your own courses. ## Versioning and stability Every file carries `format` and `formatVersion`; today `"asakiri-course"` and `1`. Gate on both, refuse a higher version, and migrate a lower one. See [Versioning and migration](/build/file-formats#versioning-and-migration). A writer has three obligations beyond a reader: Write `format` and `formatVersion` on every file you create or rewrite. Read the existing file, merge your changes over it, and write the result. Unknown keys, unknown part types, and translations for locales you are not showing must survive the round trip. Studio never rewrites the body file of a part it could not parse. Write to a temporary file and rename, so a crash cannot leave a half-written record. The following are still expected to change, so keep them behind your own boundary: * schema-template format and custom-field constraints; * packaging rules for a distributable course. ## Not specified yet Some things a real learner app needs are intentionally not in the course format, because they are product decisions that have not been made. Building them today means inventing your own model that a future Asakiri spec may not match: * **Learner state**: progress, completion, and per-item history. * **Review scheduling**: any spaced-repetition or practice-ordering rules. * **Packaging and distribution**: how a course is bundled and delivered to a learner app. If you need these now, keep them in a separate layer keyed by the stable IDs in the course, so you can re-map when an official model lands. # Resolving and rendering Source: https://docs.asakiri.com/build/resolving-and-rendering The learner-app contract: turn bindings into values, render fragments, and grade responses. Canonical format, version 1. The resolver and evaluation shapes below match `src/core/course/resolve-binding.ts` and the exercise types. A learner app does three things: resolve [bindings](/data-structure/bindings) to values, render each [fragment](/data-structure/bindings#render-fragments) by its role, and grade the response against the exercise's `evaluation`. ## Resolving bindings Build a resolver from the loaded course (a map of records and assets), then resolve any binding to a `ResolvedValue`. Resolution never throws: an unresolved reference returns `{ kind: "missing", reason }` so a broken reference degrades instead of crashing. ```ts theme={null} type ResolvedValue = | { kind: "text"; text: string; locale?: string } | { kind: "asset"; asset: Asset; label?: string; locale?: string } | { kind: "record"; record: ContentRecord } | { kind: "list"; items: readonly ResolvedValue[] } | { kind: "literal"; value: PortableValue } | { kind: "missing"; reason: string }; interface BindingResolver { resolve(binding: Binding): ResolvedValue; } const resolver = createBindingResolver(course); const value = resolver.resolve(fragment.binding); ``` How each binding resolves: | Binding | Resolves to | | --------- | ------------------------------------------------------------------------------------------------------------------ | | `literal` | `text` when the value is a string or `{ type: "text", text }`; otherwise `literal`. | | `asset` | `asset` (or `missing` if the ID is unknown). | | `record` | `record`. | | `field` | The field value: `text`, `asset`, or `list`. | | `item` | One item of a `list` field: `text` or `asset` (or `missing` if the field is not a list or the item ID is unknown). | ## Rendering fragments A `RenderFragment` pairs a binding with a `role` (`primary`, `supporting-text`, `visual`, `audio`, `translation`, and so on). Resolve the binding, then render by role and value kind: `text` values as text, `asset` values by asset `kind` (image, audio player, video), and a `record` value with its presentation. Roles are open strings; render the ones you know and fall back sensibly for the rest. ## Grading Every exercise carries one `evaluation`. Grade against stable IDs or accepted-value bindings, never against displayed text. | `evaluation.kind` | Grade by | | ------------------ | ------------------------------------------------------------------------------------------------------------------- | | `selected-options` | The chosen option IDs equal `correctOptionIds` (respect `select: "one" \| "many"`). | | `ordered-tokens` | The submitted token-ID order equals `correctOrder`. Unused `distractors` must stay unused. | | `matched-pairs` | Every submitted `{ leftId, rightId }` is in `pairs`. Extra right items are distractors. | | `filled-blanks` | For each blank, the filled option is in `correctOptionIds`, or the typed value passes `accepted` under `normalize`. | | `typed-answer` | The typed value matches an `accepted` value binding under `NormalizationRules`. | | `spoken-response` | On-device speech scoring at the given `strictness`. Not gradable off-device. | ### Accepted values and normalization Typed grading resolves `accepted` bindings (usually a field such as `field_alternate_answers`) and compares under normalization, so "a cat" and "cats" pass without duplicating text in the exercise. ```ts theme={null} interface NormalizationRules { ignoreCase?: boolean; ignoreWhitespace?: boolean; ignorePunctuation?: boolean; ignoreScript?: boolean; // e.g. accept kana for kanji } ``` `speaking` grades only in the learner app: it needs a microphone and on-device speech recognition. The exercise authors the target phrase and `strictness`; nothing is recorded or uploaded by Studio. If you build a learner app, you own the recognition and scoring for this type. ## Localized authored text Titles, instructions, and literal text fragments may be a plain string or a map of locale tag to string. Resolve against the course's `defaultLocale`, or against the learner's language if you offer one: try an exact tag, then the base language (`pt-BR` to `pt`), then any available entry. ```ts theme={null} function resolveLocalized(value: string | Record, locale: string): string { if (typeof value === "string") return value; return value[locale] ?? value[locale.split("-")[0]] ?? Object.values(value)[0] ?? ""; } ``` Taught content is not localized this way. It lives in records, where each language is its own field and list items carry their own `locale`. ## Media playback An `asset` resolution gives you the `Asset` record. Load the binary from the asset's own directory using its `file` (when `availability` is `ready`); a `placeholder` asset has no file yet. Use `mimeType` to choose the player. # Rich-text documents Source: https://docs.asakiri.com/build/rich-text The Tiptap document format and the custom nodes that embed content and media. Canonical format, version 1. The document file carries the same `format` and `formatVersion` envelope as every other file in the course, alongside the Tiptap `doc` node. Ignore those two keys when handing the document to a ProseMirror renderer. A `tiptap` part is a standard [Tiptap](https://tiptap.dev) / ProseMirror JSON document: a root `doc` node with a `content` array of nodes. Renderers can use Tiptap's own `generateHTML`, or walk the JSON directly. ```json theme={null} { "type": "doc", "content": [ { "type": "heading", "attrs": { "level": 1 }, "content": [{ "type": "text", "text": "Meet 猫" }] }, { "type": "paragraph", "content": [{ "type": "text", "text": "猫 means cat." }] } ] } ``` ## Standard nodes Documents use Tiptap StarterKit plus a few extensions. Expect these node and mark types: * **StarterKit**: `doc`, `paragraph`, `text`, `heading`, `bulletList`, `orderedList`, `listItem`, `blockquote`, `codeBlock`, `horizontalRule`, `hardBreak`, and the marks `bold`, `italic`, `strike`, `code`. * **Extensions**: `highlight` (mark), `textStyle` (mark), `table` / `tableRow` / `tableHeader` / `tableCell`, and `youtube`. ## Custom nodes Three custom nodes connect a document to reusable content and media. A learner or creator app must handle these; a generic Tiptap renderer will not. ### contentRecord Inline atom node that embeds a reusable record via a [binding](/data-structure/bindings). Resolve the binding and render according to `presentation`. ```json theme={null} { "type": "contentRecord", "attrs": { "binding": { "kind": "record", "recordId": "record_cat" }, "presentation": "vocabulary-card", "label": null } } ``` | Attr | Meaning | | -------------- | --------------------------------------------------------------------------------------------- | | `binding` | Any [`Binding`](/data-structure/bindings) (`record`, `field`, `item`, `asset`, or `literal`). | | `presentation` | A named view hint, for example `"vocabulary-card"`. | | `label` | Optional display label. | ### audio and video Block atom nodes for media. They can carry a resolved `src` or reference an asset by `assetId` (resolve `assetId` to the asset's file when present). ```json theme={null} { "type": "audio", "attrs": { "assetId": "asset_cat_ja_audio", "label": "Listen", "src": null } } ``` ```json theme={null} { "type": "video", "attrs": { "assetId": "asset_intro_clip", "label": null, "src": null } } ``` ### image The image node is Tiptap's `image` extended with an `assetId` attribute, so an image can point at a project asset instead of an external URL. Prefer `assetId` when present and resolve it to the asset's file. ## Rendering checklist 1. Render standard nodes and marks with Tiptap or your own walker. 2. For `contentRecord`, resolve `attrs.binding` with the [binding resolver](/build/resolving-and-rendering) and render by `presentation`. 3. For `audio`, `video`, and `image`, resolve `assetId` to the asset file and render the player. 4. Handle a `missing` resolution gracefully (see [Resolving and rendering](/build/resolving-and-rendering)); a broken reference should not crash the page. # Bindings and fragments Source: https://docs.asakiri.com/data-structure/bindings How lessons and exercises reference content, media, and inline literals. Lessons and exercises are made of **fragments**. Each fragment binds to a piece of reusable content, a media asset, or an intentional inline literal. Bindings are how the outline connects to the content and media it displays. ## Binding ```ts theme={null} type Binding = | { readonly kind: "record"; readonly recordId: string } | { readonly kind: "field"; readonly recordId: string; readonly fieldId: string } | { readonly kind: "item"; readonly recordId: string; readonly fieldId: string; readonly itemId: string } | { readonly kind: "asset"; readonly assetId: string } | { readonly kind: "literal"; readonly value: PortableValue }; ``` | Kind | Points at | Use it for | | --------- | ------------------------ | ------------------------------------------------------------ | | `record` | A whole record | Rendering a record with its presentation, or linking records | | `field` | One field of a record | A single translation, reading, or the primary text | | `item` | One item in a list field | A specific pronunciation, image, or example | | `asset` | A media asset directly | Standalone audio or an image not owned by a record | | `literal` | An inline value | Text that is intentionally not reused (see Detachment) | ## PortableValue Literal values are limited to validated, JSON-compatible data. Binary data and absolute paths never appear inline. ```ts theme={null} type PortableValue = | string | number | boolean | null | readonly PortableValue[] | { readonly [key: string]: PortableValue }; ``` ## Render fragments A fragment pairs a binding with a **role** that says how it is used in its context. ```ts theme={null} interface RenderFragment { readonly id: string; readonly role: string; // "primary", "supporting-text", "visual", "audio", "translation", ... readonly binding: Binding; } ``` The same record can appear in many fragments with different roles: its Japanese text as the `primary` prompt, its audio as an `audio` fragment, its English as a `translation`. ## Detachment Turning a live reference into a `literal` creates an intentional snapshot in the lesson or exercise. Detaching text or structured content copies the value inline. Detaching a presentation from a record does **not** duplicate binary media: media stays referenced by asset ID unless the author imports a distinct file. See [Reference lifecycle](/data-structure/reference-lifecycle). # Content records Source: https://docs.asakiri.com/data-structure/content-records Collections, field definitions, and records: the reusable source of truth. Content is reusable semantic knowledge organized into **collections** that display as tables. A collection defines fields; a record fills them in. Records carry globally unique IDs, so a reference does not also need to carry a collection ID. ## Collections and fields A collection has a stable ID, a name, and an ordered list of field definitions. ```ts theme={null} type FieldKind = "text" | "asset"; type FieldCardinality = "one" | "many"; interface FieldDefinition { readonly id: string; readonly name: string; readonly kind: FieldKind; // text or a reference to a media asset readonly cardinality: FieldCardinality; // "one" scalar, or "many" (a list) readonly required: boolean; readonly locale?: string; // e.g. "ja", "en" readonly assetKind?: AssetKind; // for asset fields: audio | image | video } interface Collection { readonly id: string; readonly name: string; readonly description?: string; readonly fields: readonly FieldDefinition[]; } ``` Field **IDs** are stable and independent from their visible **names**. Renaming a column never breaks references that point at it. ## Records A record belongs to one collection and stores a value per field. A field value is text, an asset reference, or a list of items. ```ts theme={null} type RecordFieldItem = | { readonly id: string; readonly kind: "text"; readonly value: string; readonly label?: string; readonly locale?: string } | { readonly id: string; readonly kind: "asset"; readonly assetId: string; readonly label?: string; readonly locale?: string }; type RecordFieldValue = | { readonly kind: "text"; readonly value: string } | { readonly kind: "asset"; readonly assetId: string } | { readonly kind: "list"; readonly items: readonly RecordFieldItem[] }; interface ContentRecord { readonly id: string; readonly collectionId: string; readonly fields: Readonly>; readonly presentations?: readonly RecordPresentation[]; } ``` A `many` field holds a `list` value. Each item in a list gets a **stable item ID**, because an author may reference one pronunciation, image, or translation independently. Scalar (`one`) fields do not need value IDs. ## Example record ```json theme={null} { "id": "record_cat", "collectionId": "vocabulary", "fields": { "japanese": { "kind": "text", "value": "猫" }, "english": { "kind": "text", "value": "cat" }, "pronunciations": { "kind": "list", "items": [ { "id": "pronunciation_primary", "kind": "asset", "label": "Japanese (speaker A)", "assetId": "asset_cat_ja_audio" }, { "id": "pronunciation_english", "kind": "asset", "label": "English (speaker B)", "assetId": "asset_cat_en_audio" } ] }, "images": { "kind": "list", "items": [{ "id": "image_primary", "kind": "asset", "assetId": "asset_cat_image" }] } } } ``` ## Presentation A record can carry one or more presentations that choose a primary field and which columns are visible. Presentation is a view concern; it never changes what the record means or how it is referenced. ```ts theme={null} interface RecordColumn { readonly fieldId: string; readonly visible: boolean } interface RecordPresentation { readonly id: string; readonly primaryFieldId: string; readonly columns: readonly RecordColumn[]; } ``` ## Writing systems Alphabets and characters are content, not a separate entity. A character is a record with text and asset fields (glyph, romanization, pronunciation audio, stroke-order media), so a writing system is a collection like any other. Three writing-system concerns are all expressible today: * **Order:** the collection's ordered record list captures canonical order (gojūon, alphabetical). * **Grid and grouping:** fields like `row` and `vowel` let a view rebuild a syllabary grid. * **Relations:** variants and derived forms (が from か) are record-to-record links via a `record` [binding](/data-structure/bindings). Prefer one collection per writing system, since field shapes differ across scripts (kanji needs readings and meanings that kana does not). # Course structure Source: https://docs.asakiri.com/data-structure/course-structure Units, lessons, and parts: the outline learners move through. A course is organized as **units**, **lessons**, and **parts**. | Level | What it is | Holds | | ---------- | ------------------------------------------- | ----------------------------------------------------------- | | **Unit** | Groups lessons and forms the course outline | An ordered list of lesson IDs | | **Lesson** | A heading learners move through | An ordered list of parts | | **Part** | The unit of content | One rich-text document, rich-media composition, or exercise | A lesson holds no content of its own. It is an ordered list of parts, and a single lesson usually mixes them, for example a rich-text introduction followed by a few exercises. ## Part kinds Each part is exactly one of: * **Rich-text document:** a [Tiptap](https://tiptap.dev) document for explanations and reading material. * **Rich-media composition:** a layout of fragments that bind to content and assets. * **Exercise:** one of the [exercise types](/data-structure/exercises). The content kinds (rich-text, rich-media, exercise) classify a **part**, not a lesson. ## Ordering Ordering is meaningful at every level. Units list lessons in order, lessons list parts in order, and collections list records in order. For a writing system, that record order captures the canonical sequence (for example the gojūon order of kana). See [Content records](/data-structure/content-records#writing-systems). # Exercises Source: https://docs.asakiri.com/data-structure/exercises Presentation and evaluation as separate concerns, across seven exercise types. Every exercise separates two things: * **Presentation:** the fragments a learner sees and the pieces they manipulate (prompt, options, tokens, pairs, blanks). These are compositions of [bindings](/data-structure/bindings), the same as any lesson content. * **Evaluation:** how a response is graded. Correctness always refers to stable IDs or to accepted-value bindings, never to duplicated display values. The stable core is a small set of evaluation strategies. An exercise `type` is an authoring and interaction template layered on top, and several types can share one strategy. ## Shared shape ```ts theme={null} type ExerciseType = | "multiple-choice" | "select-image" | "match-pairs" | "fill-blank" | "word-order" | "listening" | "speaking"; interface ChoiceOption { readonly id: string; readonly body: readonly RenderFragment[]; // may render text, audio, an image, or several } interface ExerciseBase { readonly id: string; readonly type: ExerciseType; readonly instruction?: string; readonly prompt: readonly RenderFragment[]; readonly evaluation: Evaluation; readonly feedback?: ExerciseFeedback; // correct / incorrect / per-option readonly settings?: ExerciseSettings; // per-type toggles readonly presentation?: { readonly layout?: "list" | "image-grid" }; } ``` `ChoiceOption` is reused wherever an exercise offers addressable pieces: options, pair items, and tokens. ## Evaluation strategies ```ts theme={null} type Evaluation = | { kind: "selected-options"; select?: "one" | "many"; correctOptionIds: readonly string[] } | { kind: "ordered-tokens"; correctOrder: readonly string[] } | { kind: "matched-pairs"; pairs: readonly { leftId: string; rightId: string }[] } | { kind: "filled-blanks"; blanks: readonly BlankAnswer[] } | { kind: "typed-answer"; accepted: readonly AcceptedValue[]; normalize?: NormalizationRules } | { kind: "spoken-response"; strictness: "lenient" | "standard" | "strict" }; ``` Accepted-value bindings usually point at a field such as `field_alternate_answers`, so "a cat" and "cats" both pass without duplicating text in the exercise. Typed grading uses `NormalizationRules` for case, whitespace, punctuation, or script (for example accepting kana for kanji). ## Types at a glance | Type | Learner does | Evaluation | Auto-graded in Studio | | ----------------- | ------------------------------------- | ------------------------------------ | --------------------- | | `multiple-choice` | Picks one or more options | `selected-options` | Yes | | `select-image` | Hears audio, taps the matching image | `selected-options` | Yes | | `match-pairs` | Links each item to its partner | `matched-pairs` | Yes | | `fill-blank` | Fills blanks from a bank or by typing | `filled-blanks` | Yes | | `word-order` | Orders tokens into a sentence | `ordered-tokens` | Yes | | `listening` | Hears audio, then taps or types | `selected-options` or `typed-answer` | Yes | | `speaking` | Says the phrase aloud | `spoken-response` | No, learner app only | `select-image` is mechanically `multiple-choice` with image-valued option bodies and an audio prompt. It is a distinct template that shares the `selected-options` strategy, with `presentation.layout` set to `image-grid`. ## Type-specific bodies ```ts theme={null} interface MultipleChoiceExercise extends ExerciseBase { readonly type: "multiple-choice" | "select-image"; readonly options: readonly ChoiceOption[]; } interface MatchPairsExercise extends ExerciseBase { readonly type: "match-pairs"; readonly left: readonly ChoiceOption[]; readonly right: readonly ChoiceOption[]; // extra right items are distractors } interface FillBlankExercise extends ExerciseBase { readonly type: "fill-blank"; readonly stem: readonly BlankSegment[]; // sentence with one or more blanks readonly bank?: readonly ChoiceOption[]; // present in tap-to-fill mode readonly translation?: RenderFragment; } interface WordOrderExercise extends ExerciseBase { readonly type: "word-order"; readonly tokens: readonly ChoiceOption[]; readonly distractors?: readonly ChoiceOption[]; // extra tokens that stay unused } interface ListeningExercise extends ExerciseBase { readonly type: "listening"; readonly stimulus: RenderFragment; // the audio is the whole question readonly answerMode: "select" | "type"; readonly options?: readonly ChoiceOption[]; } interface SpeakingExercise extends ExerciseBase { readonly type: "speaking"; readonly target: RenderFragment; // the phrase to say } ``` ## Distractors Distractors stay explicit for every type: options, unpaired right items, and unused tokens are all stored. Distractor queries are editor tooling only: the author selects a pool, Studio proposes candidates, and the chosen distractors are saved explicitly. ## Where grading runs Auto-gradable types resolve entirely from authored IDs, ordering, pairs, or normalized text, so Studio can preview and grade them. `speaking` is different. It needs a microphone and on-device speech recognition, so recording and scoring run only in the learner application. Studio authors the target phrase and strictness; it does not record or score, and nothing is uploaded. # Media Source: https://docs.asakiri.com/data-structure/media Assets: local binary files with technical metadata. Media represents local binary assets and their technical metadata. An asset owns a file and facts about it (kind, MIME type, duration, dimensions). Semantic roles belong to content, not media. "Japanese pronunciation by speaker A" is the meaning of a content field item; the asset it points at owns the audio file. ## Asset shape ```ts theme={null} type AssetKind = "audio" | "image" | "video"; type AssetAvailability = "ready" | "placeholder"; interface Asset { readonly id: string; readonly kind: AssetKind; readonly label: string; readonly availability: AssetAvailability; // "placeholder" until a file is attached readonly file: string | null; // local storage identity readonly expectedFile?: string; readonly mimeType: string; readonly sha256?: string; // digest of the stored file readonly byteSize?: number; readonly metadata?: Readonly>; // e.g. duration, dimensions } ``` Binary data and absolute paths never appear inline in content or lessons. Content and compositions reference an asset by its stable **ID**; the asset owns the file. ## Integrity Studio records a `sha256` digest and `byteSize` for every asset that has a local file, computed **after** the file lands in the project. That ordering matters for images, because Studio strips EXIF metadata (including GPS) on import, which changes the bytes. The digest gives players and validators an integrity check, a deduplication key, and a stable cache key. ## Placeholders An asset can exist as a `placeholder` before its file is recorded or imported, which lets authors compose a course and fill in audio or images later. Availability flips to `ready` once a file is attached. ## Supported formats Studio recognizes these extensions and maps them to a kind and MIME type: | Kind | Formats | | ----- | -------------------------------------------------- | | Image | `png`, `jpg`, `jpeg`, `gif`, `webp`, `svg`, `avif` | | Audio | `mp3`, `m4a`, `aac`, `wav`, `ogg`, `opus`, `flac` | | Video | `mp4`, `webm`, `mov` | ## Getting audio in Media can be imported in the Media area or created from any field that accepts an asset. Studio can also **record audio** in-app or generate speech with **on-device text to speech**, saving the result as a normal media file. Nothing is uploaded. # Overview Source: https://docs.asakiri.com/data-structure/overview The content-first model behind every Asakiri course. Asakiri uses a **content-first** domain model with a **context-first** authoring workflow. Reusable knowledge lives in content records, media lives as independent assets, and lessons and exercises compose *references* to those records and assets. This is database-like behavior over local project data. It does not mean the course is stored as a single database file. A course is a directory of small JSON files, specified by the [course format](/build/file-formats). ## The three domains Reusable semantic records organized into collections. Lessons and exercises reference them. Local binary assets (audio, image, video) with technical metadata. Lessons and exercises bind to records, fields, items, or assets. ## How a course fits together ``` Course └─ Units outline; each names an ordered list of lessons └─ Lessons an ordered list of parts └─ Parts rich-text | rich-media | exercise └─ Fragments each binds to content, an asset, or an inline literal Content └─ Collections a table with a stable ID and field definitions └─ Records a globally unique ID and typed field values Media └─ Assets a stable ID, kind, MIME type, and local file ``` * **Structure** ([Course structure](/data-structure/course-structure)) is the outline learners move through: units, lessons, and parts. * **Content** ([Content records](/data-structure/content-records)) is the reusable knowledge: collections of records with typed fields. * **Media** ([Media](/data-structure/media)) owns the binary files. * **Bindings** ([Bindings and fragments](/data-structure/bindings)) connect the outline to the content and media it displays. * **Exercises** ([Exercises](/data-structure/exercises)) separate what a learner sees from how a response is graded. ## Two ideas that hold it together A record, a field, a repeated item, and an asset each have a stable ID. Renaming a column or a displayed value never breaks a reference. Editing a record updates every place it is used. See [Reference lifecycle](/data-structure/reference-lifecycle). An exercise renders fragments (what the learner sees) and grades against stable IDs or accepted-value bindings (never against duplicated display text). See [Exercises](/data-structure/exercises). # Reference lifecycle Source: https://docs.asakiri.com/data-structure/reference-lifecycle How live references, usage tracking, deletion, and validation behave. References between the outline and content are **live by default**. Editing a record updates every place it is used. To keep "updates everywhere" safe, Asakiri tracks usage and refuses silent cascades. ## Usage tracking Every content record, field, item, and asset has a derived **usage list**. The usage graph is computed from project content and may be cached. It is a derived view, not an independently editable source of truth. ## Deletion and detachment * Referenced entities **cannot be silently deleted**. The author must replace references, detach usages, or explicitly leave them broken. * **Detaching** text or structured content creates an intentional [literal](/data-structure/bindings#detachment) snapshot in the lesson or exercise. * Detaching presentation from a record does **not** duplicate binary media. Media stays referenced by asset ID unless the author imports a distinct file. ## Validation A validation pass reports missing records, fields, repeated items, and assets whenever a project opens. The bundled `japanese-starter` fixture is checked in CI with `pnpm check:example`. ## What is settled vs deferred Both the **shape** of the model and the **on-disk format** are settled. The format is version 1, published with JSON Schemas: see [Project layout](/build/project-layout) and [File formats](/build/file-formats). Still deliberately undecided: * schema-template format and custom-field constraints; * whether record changes need revision history beyond git; * query-based runtime exercise generation; * packaging rules for the separate learner application. These choices stay behind repository and resolver ports, so they do not leak into feature code or Tiptap documents. Treat content tables as authoring views, not as a rule that one table equals one file: a collection is one file listing its records, and each record is a file of its own. # Asakiri Studio Source: https://docs.asakiri.com/index A local-first desktop app for building deep, structured language courses. Asakiri Studio is a local-first course editor. You build lessons, reusable content, exercises, and media on your own machine, and the whole course stays in a single project folder you control. These docs cover getting started and how a course is stored, using the content-first data model. Install the app and open your first project. How units, lessons, content records, media, and exercises fit together. Get the latest build for macOS, Windows, and Linux. The Studio repository on GitHub. ## What it is * **Local-first.** Content and media live in a project folder on your disk, not in an account or a cloud service. * **Content-first.** Reusable records are the source of truth; lessons and exercises reference them. * **Portable.** A course is plain files, so you can move it, back it up, or put it under version control. Asakiri Studio is in alpha, but the course format is settled: `asakiri-course` version 1, published with [JSON Schemas](https://github.com/AsakiriLingo/asakiri-studio/tree/main/schemas/asakiri-course/v1). See [Build on Asakiri](/build/overview) to read or write courses yourself, and [Reference lifecycle](/data-structure/reference-lifecycle) for what is still deferred. # Quickstart Source: https://docs.asakiri.com/quickstart Install Asakiri Studio and open your first course project. ## Install Download the latest build from [asakiri.com](https://asakiri.com). macOS (`.dmg`), Windows (`.exe` or `.msi`), or Linux (`.AppImage`, `.deb`, `.rpm`). On macOS the build is signed and notarized, so it opens normally. On Windows, which is not code-signed yet, choose **More info → Run anyway** at the SmartScreen prompt. A course is a single project folder. Point Studio at a new folder to start one, or open an existing project directory. ## Author content-first A course is built from reusable records, not copy-pasted values. The typical flow: In the Content area, make a collection (for example Vocabulary) and add records. A record holds fields such as the target text, a translation, and audio. Group lessons into units, then fill each lesson with parts: rich-text documents, rich-media compositions, or exercises. Lessons and exercises point at records by ID. Edit a record once and every use updates. You can also create a record inline without leaving the lesson. ## Put it under version control Because a course is just a folder of files, you can track it with git and host it on GitHub or any git provider. That gives you history, backups, and a way to collaborate when more than one person works on the same course. Next, see how the pieces fit together.