Skip to content
Documentation GitHub
Content

Page System

Status: Shipped Crates: crates/domain/src/page.rs, crates/domain/src/block.rs, crates/application/src/page/

The Page System is the primary content management subsystem. Pages are the fundamental content unit in a workspace — each page has a title, a URL-safe slug, and one or more blocks of content. Pages form a parent-child hierarchy where folders are themselves pages (“folders ARE pages” model), and connect to each other through wiki-style links ([[Display|slug]]) with automatic backlink tracking.

All pages are stored as rows in a per-workspace SQLite database (inklings.db). Block content is stored as Loro CRDT BLOBs (content_loro column) which serve as the source of truth for editor state and operation history. Materialized text is kept alongside for FTS5 full-text search indexing.

crates/domain/src/
page.rs # Page entity, PageParts, slugify(), validate_slug()
page_type.rs # PageType enum (Page, Folder)
page_tree.rs # PageTreeNode (sidebar tree representation)
page_detail.rs # PageDetail (context panel composite)
block.rs # Block entity, validate_area_name()
block_content_type.rs # BlockContentType enum (Markdown, Image)
reference.rs # Reference entity (wiki-link tracking)
search.rs # SearchResult entity
crates/application/src/page/
mod.rs # Re-exports all use cases
services.rs # PageRepository + ReferenceRepository traits
create.rs # CreatePageUseCase
get.rs # GetPageUseCase
get_detail.rs # GetPageDetailUseCase
update.rs # UpdatePageUseCase
delete.rs # DeletePageUseCase (soft delete)
permanent_delete.rs # PermanentDeletePageUseCase
empty_trash.rs # EmptyTrashUseCase
restore.rs # RestoreDeletedPageUseCase
list.rs # ListPagesUseCase
list_tree.rs # ListPageTreeUseCase
list_filtered_tree.rs # ListFilteredTreeUseCase
list_deleted.rs # ListDeletedPagesUseCase
move_page.rs # MovePageUseCase
rename.rs # RenamePageUseCase (with link propagation)
search.rs # SearchPagesUseCase
export.rs # ExportPagesUseCase
set_icon.rs # SetPageIconUseCase
set_type.rs # SetPageTypeUseCase
create_block.rs # CreateBlockUseCase
create_block_in_area.rs # CreateBlockInAreaUseCase
save_block_content.rs # SaveBlockContentUseCase (CRDT BLOB + text)
save_block_content_by_id.rs # SaveBlockContentByIdUseCase (per-cell grid)
get_block_snapshot.rs # GetBlockSnapshotUseCase (CRDT BLOB retrieval)
update_block_metadata.rs # UpdateBlockMetadataUseCase
get_backlinks.rs # GetBacklinksUseCase
get_outgoing_links.rs # GetOutgoingLinksUseCase
get_descendants.rs # GetPageDescendantsUseCase
apply_layout.rs # ApplyLayoutUseCase
remove_layout.rs # RemoveLayoutUseCase
assign_block_area.rs # AssignBlockToAreaUseCase
check_layout_compatibility.rs # CheckLayoutCompatibilityUseCase
update_references.rs # UpdateReferencesUseCase
wiki_link_parser.rs # Wiki-link parsing and replacement
  • Folders ARE pages. A folder is a page with page_type = Folder and child pages. Every folder has its own content — there is no separate folder concept.
  • SQLite storage, not files. Pages are rows in inklings.db, not markdown files on disk. One database per workspace.
  • Loro CRDT source of truth. Block content is stored as LoroDoc binary snapshots in content_loro BLOB columns. The content TEXT column holds materialized text derived from the CRDT, used only for FTS5 indexing and debug inspection. The CRDT BLOB is passed through untouched — never re-serialized from materialized text.
  • FTS5 3-column index. Full-text search uses a 3-column FTS5 index over title, content, and tags with weighted BM25 scoring (bm25(10.0, 1.0, 5.0)).
  • Specta type generation. All domain structs derive specta::Type for automatic Rust-to-TypeScript type generation into packages/contracts/generated/. No hand-written TypeScript interfaces.
  • Slug-based addressing. Pages are identified by a URL-safe slug derived from the title (e.g., "The Hero's Journey" becomes "the-hero-s-journey"). Hierarchical pages use path slugs (e.g., "characters/hero").
  • Capability-gated operations. Every use case requires a PermissionGuard and checks the appropriate capability (PagesWrite, PagesRead, PagesDelete, PagesOrganize).
  • Two-phase deletion. Delete moves pages to trash (soft delete). Users can restore or permanently delete from trash. EmptyTrashUseCase purges all trashed pages.
RuleEnforcementLocation
Min 1 block per pagePage::new() creates an initial empty block; Page::from_parts() rejects empty blocks unless a layout is defineddomain/src/page.rs
No cycles in hierarchyPageTreeNode::would_create_cycle() validates before move; repository move_page() enforcesdomain/src/page_tree.rs, repository
Cascade deletionsoft_delete(slug, cascade=true) marks all descendants as deletedPageRepository::soft_delete()
Workspace scopingAll pages belong to exactly one workspace databaseInfrastructure (one inklings.db per workspace)
Title validationNon-empty, max 200 characters, must produce a non-empty slugCreatePageUseCase, UpdatePageUseCase
Unique slugsRepository rejects duplicate slugs within a workspacePageRepository::create(), exists_by_slug()
Block content type is immutableA Markdown block cannot become an Image block after creationBlockContentType design — no mutation method
Slug safetyvalidate_slug() rejects path traversal, absolute paths, null bytes, backslashesdomain/src/page.rs
Page {
id: Uuid,
title: String,
page_type: PageType, // Page | Folder
template: Option<String>,
icon: Option<String>,
icon_color: Option<String>,
blocks: Vec<Block>, // Invariant: non-empty (unless layout is Some)
frontmatter: HashMap<String, Value>,
type_assignments: Vec<TypeAssignment>,
layout: Option<Layout>, // CSS Grid template areas
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
Block {
id: Uuid,
slot_id: u32, // Position index within the page
content: String, // Materialized text (markdown or alt text)
content_type: BlockContentType, // Markdown | Image(ImageBlockMetadata)
area: Option<String>, // Named CSS Grid area assignment
}
  • Markdown (default): Rich text edited through TipTap/Loro. Content stored as LoroDoc BLOB.
  • Image(ImageBlockMetadata): References an attachment UUID with display settings (scale, alignment, caption, link URL). The content field holds alt text. Metadata stored as JSON in content_type_metadata column.

Lightweight representation for sidebar navigation. Contains slug, title, id, page type, icon, type slugs, and recursive children. Supports cycle detection via would_create_cycle().

Composite type for the context panel combining page metadata with computed statistics (word count, backlink count, outgoing link count) and full link details.

  1. CreatePageUseCase.execute() validates title (non-empty, max 200 chars, produces non-empty slug)
  2. Page::new(title) creates the page with one initial empty block
  3. If parent_slug is provided, validates parent exists, calls repository.create_child()
  4. Otherwise checks slug uniqueness via exists_by_slug(), calls repository.create()
  5. Returns CreatePageResponse { page, slug }
  1. SaveBlockContentUseCase.execute() receives slug, raw CRDT content_blob, and content_text
  2. Validates BLOB is non-empty
  3. Calls repository.save_block_content_blob(slug, content_blob, content_text)
  4. Infrastructure writes BLOB to content_loro column and text to content column atomically
  5. For grid layouts, SaveBlockContentByIdUseCase targets a specific block by UUID
  1. RenamePageUseCase.execute() loads the page, validates the new title
  2. Calls repository.rename(slug, new_title) to update title and recalculate slug
  3. Queries reference_repository.get_backlinks() to find all pages linking to the old slug
  4. For each linking page, rewrites [[Display|old-slug]] to [[Display|new-slug]] in block content
  5. Updates the reference index via reference_repository.update_target_slugs()
  6. A separate preview() method shows what would change without committing
  1. DeletePageUseCase.execute() checks PagesDelete capability
  2. Calls repository.soft_delete(slug, cascade) which marks page(s) as deleted
  3. Pages remain in database but are excluded from normal queries
  4. RestoreDeletedPageUseCase can undo; PermanentDeletePageUseCase removes permanently
  1. MovePageUseCase.execute() checks PagesOrganize capability
  2. Delegates to repository.move_page(slug, new_parent_slug)
  3. Repository validates no cycle via tree traversal
  4. Converts target parent to folder if needed
  5. Moves page and all descendants, returns new slug
Error TypeVariantsWhen Raised
PageRepositoryError::NotFoundPage slug does not existget_by_slug, rename, move_page
PageRepositoryError::AlreadyExistsDuplicate slug in workspacecreate
PageRepositoryError::InvalidDataEmpty title, title too long, empty slugcreate, update, rename
PageRepositoryError::InvalidOperationPermission denied, unsupported operationCapability checks, unimplemented defaults
PageRepositoryError::ValidationDomain validation failureEmpty BLOB, invalid area name
PageRepositoryError::CrdtDecodeErrorLoroDoc BLOB cannot be decodedget_block_content_blob
DomainError::InvalidStateEmpty blocks without layoutPage::from_parts()
DomainError::ValidationInvalid area name, layout area mismatchBlock::set_area(), Page::apply_layout()
  • Layout System — CSS Grid layout support is embedded in the Page entity
  • Wiki-Link Systemwiki_link_parser.rs and update_references.rs in the page module
  • Tag System — tags are associated via page_tags junction table, FTS5 3-column index includes tags
  • Attachment System — Image blocks reference attachments by UUID

Was this page helpful?