@lodado/sdui-document
v1.0.0
Published
Headless SDUI block document domain package.
Readme
@lodado/sdui-document
Headless block document domain for SDUI — schema, patches, permissions, and SDUI adapter without editor or database coupling.
Quick start · Philosophy · Architecture · Patches · Adapters · Development
@lodado/sdui-document owns what a document means: block tree semantics, patch-based edits, permission policy, autosave state, and conversion to SDUI layout shape. It deliberately excludes MobX, ProseMirror, Yjs, React, and concrete storage.
SduiDocumentContent → patches → applyDocumentPatch → toSduiLayoutDocument → SduiLayoutRendererPairs with:
| Package | Role |
| ----------------------------- | -------------------------------------------------- |
| @lodado/sdui-document | Document meaning, tree rules, patches |
| @lodado/sdui-template | SDUI normalization, subscriptions, React rendering |
| @lodado/sdui-document-react | React block editor UI |
End-to-end example
| ① block tree | → | ② SDUI adapter | → | ③ render | | ---------------- | --- | ------------------ | --- | ------------ |
import { SduiLayoutRenderer } from '@lodado/sdui-template'
import { sduiComponents } from '@lodado/sdui-template-component'
import { createDocumentBlock, toSduiLayoutDocument, type SduiDocumentContent } from '@lodado/sdui-document'
const content: SduiDocumentContent = {
schemaVersion: '1.0',
root: createDocumentBlock({
id: 'root',
type: 'document.root',
children: [
createDocumentBlock({
id: 'heading',
type: 'document.heading',
state: { text: 'Project notes', level: 1 },
}),
createDocumentBlock({
id: 'paragraph',
type: 'document.paragraph',
state: { text: 'Rendered through SDUI.' },
}),
],
}),
}
const layoutDocument = toSduiLayoutDocument(content, {
documentId: 'doc-1',
title: 'Project notes',
})
export function DocumentPreview() {
return <SduiLayoutRenderer document={layoutDocument} components={sduiComponents} />
}Table of Contents
- Why this exists
- Philosophy
- Architecture
- Installation
- Quick start
- Creating content
- Applying block patches
- Document tree operations
- Permissions
- Autosave
- Content helpers
- Adapter contracts
- Recommended flow
- For AI assistants
- Development
- Design constraints
Why this exists
Notion-like products need a stable document model that survives renderer, backend, and editor changes. Coupling document semantics to ProseMirror transactions or React state makes testing and server-side validation painful.
This package provides a clean-room contract: pure functions, immutable patches, and adapter interfaces for persistence, storage, search, and future collaboration.
Philosophy
1. Documents are domain data, not editor internals
SduiDocument
└─ SduiDocumentContent
└─ SduiDocumentBlock
├─ document.heading
├─ document.paragraph
├─ document.checklist
└─ document.linkNo ProseMirror transactions, MobX observables, Yjs updates, or DOM selection in the public API.
2. SDUI renders; this package defines meaning
toSduiLayoutDocument lowers semantic blocks into an SDUI-compatible shape. @lodado/sdui-template renders it.
3. Patch-based editing
block.insert | block.update | block.delete | block.moveEnables autosave, optimistic UI, version conflicts, and future collaboration without a rich-text engine.
4. Block-level first
Deferred until proven necessary: character-level CRDT, ProseMirror plugins, complex inline marks, table cell selection, concrete storage/search backends.
5. Pure functions first
Patch application, tree moves, permissions, autosave, traversal, and text extraction are pure TypeScript — safe in client, server, and worker contexts.
Architecture
src/
schema/ Public document, block, patch, event, workspace types
content/ Traversal, plain text, link extraction
blocks/ Patch engine and block-level errors
tree/ Document parent/collection tree operations
permissions/ Pure role/action policy
autosave/ Autosave reducer / state machine
repositories/ Persistence contract
storage/ Attachment storage contract
search/ Search indexer contract
collaboration/ Future collaboration adapter contract
sdui/ Document content → SDUI layout adapter| Layer | Responsibility | Should not do |
| ------------- | ---------------------------- | ------------------------ |
| schema | Stable public contracts | Runtime persistence |
| blocks | Apply patches | Talk to APIs or React |
| tree | Move/archive document nodes | Edit block content |
| permissions | Actor/action decisions | Trust client-only checks |
| autosave | Track save state | Timers or network calls |
| sdui | Convert blocks to SDUI nodes | Own document semantics |
Installation
Monorepo workspace:
{
"dependencies": {
"@lodado/sdui-document": "workspace:*"
}
}pnpm installQuick start
See end-to-end example above.
Creating document content
import { createDocumentBlock, type SduiDocumentContent } from '@lodado/sdui-document'
const content: SduiDocumentContent = {
schemaVersion: '1.0',
root: createDocumentBlock({
id: 'root',
type: 'document.root',
children: [
createDocumentBlock({
id: 'todo-1',
type: 'document.checklist',
state: { text: 'Ship headless core', checked: true },
}),
createDocumentBlock({
id: 'callout-1',
type: 'document.callout',
state: { text: 'Keep rich text out until proven.' },
}),
],
}),
}Supported block types
All types are defined in src/block-types/ and registered in BLOCK_TYPE_MODULES:
| Type | Description |
| ----------------------------------------- | ------------------------------------------------ |
| document.root | Document root |
| document.paragraph | Body text (state.text) |
| document.heading | Heading levels 1–3 (state.level, state.text) |
| document.bulletedList | Bulleted list item |
| document.numberedList | Numbered list item |
| document.checklist | Checkbox item (state.checked, state.text) |
| document.quote | Block quote |
| document.callout | Callout / notice block |
| document.code | Code block |
| document.divider | Horizontal rule |
| document.toggle | Collapsible section (children nested inside) |
| document.image | Image |
| document.video | Video |
| document.embed | Embed / iframe |
| document.file | File attachment |
| document.link | Link to another document |
| document.bookmark | URL bookmark preview |
| document.columnList / document.column | Multi-column layout |
| document.collection | Database / table / gallery |
| document.page | Sub-page link |
| document.toc | Table of contents |
| document.tags | Tag chips |
| document.button | Action button |
| document.sdui | Embedded SDUI layout inside a block |
// Legacy union excerpt — see blocks/schema/block.ts for the authoritative list
type SduiDocumentBlockType = 'document.root' | 'document.paragraph' | 'document.heading'
// … see table above for full setApplying block patches
Immutable — returns new content, never mutates input.
import { applyDocumentPatch, createDocumentBlock } from '@lodado/sdui-document'
// Insert
const next = applyDocumentPatch(content, {
type: 'block.insert',
parentId: 'root',
before: 'existing-block-id',
block: createDocumentBlock({
id: 'new-paragraph',
type: 'document.paragraph',
state: { text: 'A newly inserted block.' },
}),
})
// Update
applyDocumentPatch(next, {
type: 'block.update',
blockId: 'new-paragraph',
state: { text: 'Updated text' },
})
// Move
applyDocumentPatch(next, {
type: 'block.move',
blockId: 'new-paragraph',
parentId: 'root',
after: null,
})
// Delete
applyDocumentPatch(next, {
type: 'block.delete',
blockId: 'new-paragraph',
})
// Split text block at inline offset
applyDocumentPatch(next, {
type: 'block.split',
blockId: 'new-paragraph',
offset: 5,
newBlockId: 'split-block',
})
// Merge into previous block
applyDocumentPatch(next, {
type: 'block.merge',
blockId: 'split-block',
intoBlockId: 'new-paragraph',
})
// Turn into another block type
applyDocumentPatch(next, {
type: 'block.setType',
blockId: 'new-paragraph',
blockType: 'document.heading',
attributes: { level: 2 },
})Block ordering (schema 1.1)
Sibling order uses fractional position strings. Anchors replace numeric indices:
| Anchor | Meaning |
| ----------------------- | --------------------------------------- |
| after: null | Prepend as first child |
| before: null | Append as last child |
| after: 'block-id' | Insert after sibling |
| before: 'block-id' | Insert before sibling |
| fallbackAfter: ['id'] | Offline replay when after was deleted |
Schema 1.0 documents migrate to 1.1 lazily on first patch.
Errors: BlockNotFoundError, ParentBlockNotFoundError, InvalidBlockMoveError, RootBlockCannotBeDeletedError
Document tree operations
Manage relationships between documents (not blocks inside a document):
import { moveDocument, archiveDocumentSubtree, restoreDocumentSubtree } from '@lodado/sdui-document'
moveDocument({
documents,
documentId: 'doc-a',
targetParentDocumentId: 'doc-b',
targetIndex: 0,
})
archiveDocumentSubtree({ documents, documentId: 'doc-a' })Errors: DocumentNotFoundError, InvalidDocumentDestinationError
Permission policy
Pure decisions from actor role + action:
import { canPerformDocumentAction, getDocumentAccessMode } from '@lodado/sdui-document'
canPerformDocumentAction({ actor, action: 'read' }) // boolean
getDocumentAccessMode({ actor }) // 'none' | 'readOnly' | 'editable'| Actor role | Read | Update | | ------------------ | ---- | ------ | | workspace admin | yes | yes | | collection manager | yes | yes | | collection editor | yes | yes | | collection viewer | yes | no | | document editor | yes | yes | | guest (no role) | no | no |
Client checks are UX gating only. Server adapters must re-check before writes.
Autosave state machine
Pure reducer — no timers, debounce, or fetch:
import { createInitialAutosaveState, reduceAutosaveState } from '@lodado/sdui-document'
let state = createInitialAutosaveState()
state = reduceAutosaveState(state, { type: 'local.change', patchCount: 1 })
state = reduceAutosaveState(state, { type: 'save.request' })
state = reduceAutosaveState(state, {
type: 'save.success',
acknowledgedVersion: state.localVersion,
})Statuses: idle | dirty | saving | saved | failed | offline
- Stale save success is ignored
- Save failure preserves pending patches
- Offline edits return to
dirtywhen back online
Content helpers
import { extractPlainText, extractDocumentLinks, walkDocumentBlocks } from '@lodado/sdui-document'
extractPlainText(content) // plain text for search/preview
extractDocumentLinks(content) // backlink extraction
walkDocumentBlocks(content, fn) // depth-first traversalAdapter contracts
Interfaces only — no concrete implementations:
| Interface | Purpose |
| ---------------------------------- | -------------------------------------------- |
| SduiDocumentRepository | getDocument, savePatches, moveDocument |
| SduiDocumentAttachmentStorage | Upload/download URLs |
| SduiDocumentSearchIndexer | Index, remove, search |
| SduiDocumentCollaborationAdapter | Future block-level collaboration |
Implement for REST, Next.js routes, IndexedDB, Postgres, S3, or search providers.
Recommended application flow
1. Load SduiDocument from repository/API
2. Check access with canPerformDocumentAction
3. Render via toSduiLayoutDocument + SduiLayoutRenderer
4. Convert UI edits → SduiDocumentPatch
5. Apply locally with applyDocumentPatch (optimistic UI)
6. Persist patches through SduiDocumentRepository
7. Track save status with reduceAutosaveState
8. Extract text/links for search as async side effectStorybook: apps/docs/src/stories/SduiDocument.stories.tsx
For AI assistants
MCP (
@lodado/sdui-mcp) covers SDUI layout JSON, not this package. Read this README and docs/AI-ASSISTANT-GUIDE.md for block document work.
When to use this package
- Server-side or client-side patch validation before save
- Headless document transforms (no React, no ProseMirror)
- Permission checks with
canPerformDocumentAction - Autosave state machine with
reduceAutosaveState - Converting block content → SDUI layout with
toSduiLayoutDocument
Do not use this package for
- React editor UI →
@lodado/sdui-document-react - Rendering SDUI JSON →
@lodado/sdui-template - Arbitrary rich-text engine APIs → patches + block schema only
Authoring checklist
- Root block is always
type: 'document.root' - Unique
idon every block - Text blocks:
state.text(string) for initial/simple content - Use anchor placement (
after/before), not array indices - Apply patches immutably — never mutate
contentin place - Re-validate permissions on the server before persisting
Key exports
// Content
createDocumentBlock, type SduiDocumentContent, type SduiDocumentBlock
// Patches
applyDocumentPatch, type SduiDocumentPatch
// Tree (documents, not blocks)
moveDocument, archiveDocumentSubtree, restoreDocumentSubtree
// Permissions & autosave
canPerformDocumentAction, getDocumentAccessMode
createInitialAutosaveState, reduceAutosaveState
// Content helpers
extractPlainText, extractDocumentLinks, walkDocumentBlocks
// SDUI bridge
toSduiLayoutDocument
// Contracts (implement in your app)
type SduiDocumentRepository
type SduiDocumentAttachmentStorage
type SduiDocumentSearchIndexerMCP connection (for SDUI layout inside document.sdui blocks)
If your block document embeds SDUI widgets (document.sdui blocks), connect @lodado/sdui-mcp for layout JSON authoring — see root README MCP section.
Development
pnpm --filter @lodado/sdui-document test
pnpm --filter @lodado/sdui-document lint
pnpm --filter @lodado/sdui-document buildDesign constraints
Do not add yet
- React editor components
- Concrete DB / storage / search clients
- Yjs/Hocuspocus, ProseMirror, MobX
One-line rule
sdui-document owns meaning.
sdui-template owns rendering.
patches express edits.
adapters connect infrastructure.License
MIT
