Architecture
SideDoc has two code packages and one data source:
@sidedoc/backend(app/sidedoc-backend): a TypeScript library with no UI and no server. It holds the document model, schema logic, validation, exports and storage providers.sidedoc-frontend(app/sidedoc-frontend): a Vue 3 app that bundles the backend and renders the editor.- The schemas repository: profiles, type library, vocabularies, help texts and mapping files. The app reads them as static JSON at run time; the backend build copies two derived files into its source.
Everything runs in the browser (ADR 0002). There are no API calls to a SideDoc server; the only network traffic is fetching the app's own static files and, if the user connects one, a storage provider.
Backend layers
The backend follows a layered layout under src/lib/. src/index.ts re-exports src/lib/index.ts, which is the public API documented in the API reference.
Domain (src/lib/domain/)
Pure logic with no I/O.
contracts.ts: the shared types (WorkspaceSnapshot,DocumentInstance,DocumentProfile,FieldValue,ValidationIssue, ...) and the service interfaces collected inServicesApi. Lists such asSERVICE_NAMES,EXPORT_FORMATSandSTORAGE_PROVIDER_KINDSare the change points when you add a service, format or provider (seesrc/lib/domain/README.md).entities/:Workspace,DocumentandFolder, the active entities described below.shadow-model.ts: functions that work on document data against a profile schema: resolve the schema node for a path, normalize legacy value shapes, build an empty value from a schema, apply a patch at a path, and build the editing shadow (createShadow).schema-utils.ts: loading profiles, type libraries, vocabulary attachments and help texts from URLs (loadProfileFromSchemaUrls).
Application (src/lib/application/)
Implementations and orchestration on top of the domain.
| Folder | Contents |
|---|---|
container/ | ServiceContainer: holds one instance per service name, created lazily from a factory unless you pass an override. |
factories.ts | WorkspaceFactory, ServicesFactory, AppFactory for setting up a workspace and its services. |
services/core/ | Application services: workspace, document, folder, validation (AJV plus custom checks), shadow (ShadowApplicationService), vocabulary, transformation. |
services/records/ | The active-record API: ActiveRecordSession, WorkspaceRecord, DocumentRecord. |
services/adapters/ | Small factories that give UI code schema-aware helpers: profile shadow, editor session, value rendering, union rendering, editor node view model, value tree. |
services/transformers/ | Export to Markdown, HTML, PDF and RDF (JSON-LD). See Exports and mappings. |
services/persistence/ | Remote and local storage providers and the PersistenceUiController. See Persistence. |
services/testing/stubs.ts | Placeholder *ServiceImpl classes the container uses when no override is given. Their methods throw Not implemented. |
Infrastructure (src/lib/infrastructure/)
IndexedDBServices.ts: IndexedDBWorkspaceService and IndexedDBDocumentService, the browser storage behind the workspace. One database (sidedoc) with a workspace store (a single workspace snapshot) and a documents store.
Testing (src/lib/testing/)
InMemoryWorkspaceService, InMemoryDocumentService and mock services for tests and demos. They are part of the public API so frontend tests can use them too.
Build-time data
The backend runs in the browser and cannot read files at run time, so data it needs is imported as JSON and bundled by tsup:
services/core/materialized-vocabulary-index.generated.json(from the SKOS vocabularies);services/transformers/dcat/*.mapping.generated.json(from the DCAT-AP mapping files).
Both are regenerated by the prebuild scripts from SIDEDOC_SCHEMAS_DIR and committed. AJV is a dev dependency of the backend, which means tsup bundles it into dist/ rather than leaving it as an external import.
Active entities
The domain uses an active-entity model: a Workspace is the aggregate root that owns its documents and folders, and changing a Document persists through its workspace. The full description is in ACTIVE_ENTITY_ARCHITECTURE.md; this is the summary.
Workspaceis constructed from aWorkspaceSnapshotand aWorkspaceService(IndexedDB in the browser, in-memory in tests). It creates, imports and deletes documents, manages the folder tree, and holds the profile schemas and type library (updateSchemaBundle).Documentmethods (setField,setTitle,addTag,removeTag,updateQA) update the data and the audit timestamps and notify the workspace through a change listener.setFieldfirst checks that the field exists in the profile and respects its maximum occurrences; full schema validation is a separate step (ValidationApplicationService).- On a change, the workspace serializes itself to a snapshot and calls
WorkspaceService.updateWorkspace(). There is no separate save call. setAutoSave(false)suspends this for batch changes; callsave()when done.
Document.setField()
-> check field exists and max occurrences
-> update data and audit.updatedAt
-> notify listener
-> Workspace persists its snapshot
-> WorkspaceService.updateWorkspace() (IndexedDB transaction)The application layer wraps entities in records (ActiveRecordSession -> WorkspaceRecord -> DocumentRecord). A record pairs an entity with the application services, so UI code can call doc.patchByPath(path, value), doc.validate() or doc.exportHtml() without passing services around. The record methods delegate to the application services, which in turn work on the active workspace.
How the frontend uses the backend
src/services/backend-setup.ts builds the backend once per page:
- A
ServiceContainerwithIndexedDBWorkspaceServiceandIndexedDBDocumentServiceas overrides. - Application services (
WorkspaceApplicationService,DocumentApplicationService,ValidationApplicationService,TransformationApplicationService) and the vocabulary service. - An
ActiveRecordSessionover those services.
The result is provided to the Vue app as backend (app.provide('backend', backend)), and components get it with inject('backend').
The standalone editor's state lives in src/standalone/composables/useStandaloneController.ts (workspace loading, schema version checks, import, export, validation). The editing view, WorkspaceEditorView.vue, creates a ShadowApplicationService for the selected document's profile (through createProfileShadowAdapter) and an editor session (createEditorSessionAdapter), and provides editorCtx to the form renderer. See Editor rendering.
The rule for new code: schema traversal, normalization, validation and export logic belong in the backend; Vue components render and forward events.
Data flow
Google Sheet ──schemaGen.py──> profile + type library JSON (schemas repo)
│ published to sidedoc.app/schemas/<version>/
│ committed copy in app/sidedoc-frontend/public/schemas/
v
schemas/versions.json -> list of profiles per version
│ loadProfileFromSchemaUrls()
v
DocumentProfile (+ type library, vocab attachments, help)
│ Workspace.updateSchemaBundle()
v
document data ──ShadowApplicationService──> editing shadow
│ editorCtx, EditorNode tree
v
user edits -> applyPatch / patchByPath -> IndexedDB
│
v
ValidationApplicationService (AJV + custom checks) -> issues, QA score
│
v
TransformationApplicationService -> HTML, Markdown, PDF, DCAT-AP JSON-LDStep by step:
- Schema. Profiles and the type library are generated in the schemas repository (ADR 0004) and published per version. Each field carries an
x-sidedocblock with its id (for exampleds:f00100), labels, occurrence limits and UI hints. - Profile.
src/config/schemaManifest.tsreadsschemas/versions.jsonto find the profiles of a version.src/config/schemaResolver.tsloads the profile and the type library of the same version withloadProfileFromSchemaUrlsand caches them. A document declares its version in$schema(for examplehttps://sidedoc.app/schemas/0.2.2/datasheet.json), so documents of different versions can be open in one session, each against its own schema. The pinned version isVITE_SCHEMA_VERSION(runtime config), default0.2.2. - Conversion.
src/config/schemaConversion.tsupgrades a document to a newer version on request: normalize against the target schema, apply per-step transforms, stamp the new$schema. It returns a new object and leaves the original alone. - Editor. The shadow service normalizes the stored data and merges it into a template built from the schema, so required structure exists before the user types. Each edit is applied by path, coerced to the schema type and stored through the document service.
- Validation.
ValidationApplicationServicecompiles the profile with AJV (draft 2020-12, withajv-formats) and adds checks for agent types, maximum occurrences and missing English text in multilingual fields. - Export. The transformation service renders HTML, Markdown and PDF directly from the data, and JSON-LD through declarative mapping files. See Exports and mappings.
Multilingual values
Text values store all languages inside the value as a language map: { "type": "Literal", "text": { "en": "...", "nl": "..." } }. RichText has the same shape plus a markDown map. The UI edits one language at a time (the selected language) but keeps the others. Older shapes ({ text, lang } objects, per-language arrays, bare strings) are still read and normalized on load. Helpers: src/components/editor/langMap.ts in the frontend, the normalization code in shadow-model.ts in the backend.