Skip to content

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 in ServicesApi. Lists such as SERVICE_NAMES, EXPORT_FORMATS and STORAGE_PROVIDER_KINDS are the change points when you add a service, format or provider (see src/lib/domain/README.md).
  • entities/: Workspace, Document and Folder, 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.

FolderContents
container/ServiceContainer: holds one instance per service name, created lazily from a factory unless you pass an override.
factories.tsWorkspaceFactory, 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.tsPlaceholder *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.

  • Workspace is constructed from a WorkspaceSnapshot and a WorkspaceService (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).
  • Document methods (setField, setTitle, addTag, removeTag, updateQA) update the data and the audit timestamps and notify the workspace through a change listener. setField first 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; call save() when done.
text
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:

  1. A ServiceContainer with IndexedDBWorkspaceService and IndexedDBDocumentService as overrides.
  2. Application services (WorkspaceApplicationService, DocumentApplicationService, ValidationApplicationService, TransformationApplicationService) and the vocabulary service.
  3. An ActiveRecordSession over 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 ​

text
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-LD

Step by step:

  1. Schema. Profiles and the type library are generated in the schemas repository (ADR 0004) and published per version. Each field carries an x-sidedoc block with its id (for example ds:f00100), labels, occurrence limits and UI hints.
  2. Profile. src/config/schemaManifest.ts reads schemas/versions.json to find the profiles of a version. src/config/schemaResolver.ts loads the profile and the type library of the same version with loadProfileFromSchemaUrls and caches them. A document declares its version in $schema (for example https://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 is VITE_SCHEMA_VERSION (runtime config), default 0.2.2.
  3. Conversion. src/config/schemaConversion.ts upgrades 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.
  4. 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.
  5. Validation. ValidationApplicationService compiles the profile with AJV (draft 2020-12, with ajv-formats) and adds checks for agent types, maximum occurrences and missing English text in multilingual fields.
  6. 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.