Developer Docs

Blog Editor UX

The Blog Editor is the authoring surface used by Malet Owners to create, edit, and publish blog content on their Malets. The upgraded editor adds four key capabilities: auto-save drafts, Tiptap JSON validation, version history with restore, and a publish approval pipeline (DRAFT โ†’ REVIEW โ†’ APPROVED โ†’ PUBLISHED/SCHEDULED).

NOTE

The blog editor is accessed from The Deck or directly via the Admin Dashboard. Blog content is managed by the blogs subgraph.

Architecture

The editor is separated into a store layer and a UI layer, following the Svelte 5 runes pattern used across all Mallnline dashboard components.

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚           BlogForm.svelte         โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚  โ”‚ Autosave โ”‚  โ”‚ History Drawer โ”‚ โ”‚
โ”‚  โ”‚ Bar      โ”‚  โ”‚ (slide-in)     โ”‚ โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  โ”‚ TiptapEditor.svelte            โ”‚
โ”‚  โ”‚  (ProseMirror + word count)    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  โ”‚ Publish Pipeline Actions       โ”‚
โ”‚  โ”‚  Draft โ†’ Review โ†’ Publish      โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚                 โ†•
โ”‚      blogEditorStore.svelte.ts
โ”‚     (localStorage snapshots,
โ”‚      Zod JSON validation,
โ”‚      pipeline state machine)
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Store: `blogEditorStore.svelte.ts`

A Svelte 5 runes class (BlogEditorStore) that manages:

  • Content state โ€” title, slug, excerpt, coverImage, tags, html, json, stage
  • Auto-save timer โ€” 30-second debounced timer triggered by markDirty()
  • Draft snapshots โ€” localStorage-backed array of DraftSnapshot objects
  • Tiptap JSON validation โ€” Zod schema (TiptapDocSchema) validates ProseMirror output
  • Publish pipeline โ€” state machine with canTransition() guard + transitionTo() mutator

Component: `BlogForm.svelte`

The UI layer consumes the store and renders:

  • Autosave indicator bar โ€” stage badge (color-coded), JSON validation badge (โœ“ / โœ—), dirty/saved status, manual save button, history toggle
  • Title input โ€” Headline-style 2.5rem input
  • Meta panel โ€” URL slug (auto-generated from title), cover image uploader, excerpt textarea, tag input
  • Editor โ€” TiptapEditor with real-time word/character count footer
  • Schedule picker โ€” datetime-local input for scheduling publications
  • Pipeline action bar โ€” context-sensitive buttons based on current stage

Auto-Save System

Parameter Value
Debounce delay 30 seconds
Storage backend localStorage (blog_drafts_{postId})
Max snapshots 20 per post (oldest trimmed)
Snapshot contents title, html, json, excerpt, tags, coverImage, timestamp, stage
Manual save ๐Ÿ’พ button in the autosave bar

Each snapshot is a DraftSnapshot with a unique ID (snap_{timestamp}_{random}). When localStorage reaches capacity, the store gracefully degrades to keeping only the 5 most recent snapshots.

Tiptap JSON Validation

The TiptapEditor emits both HTML and ProseMirror JSON on every content change. The store validates the JSON against a Zod schema:

const TiptapDocSchema = z.object({
  type: z.literal('doc'),
  content: z.array(ProseMirrorNodeSchema).optional()
});

The validation badge in the autosave bar shows:

  • โœ“ Valid (green badge) โ€” document conforms to the ProseMirror spec
  • โœ— Invalid (red badge) โ€” with a tooltip showing the Zod error message

TIP

The JSON validation uses z.object({}).passthrough() for ProseMirror node attrs instead of z.record() for Zod v4 compatibility. Nested content is validated one level deep; deeply nested nodes use passthrough to avoid circular schema issues.

Version History

The Draft History Drawer is a slide-in panel from the right edge of the viewport.

Drawer Features

  • Reverse-chronological list of all saved snapshots
  • Each card shows: title (truncated), timestamp, color-coded stage badge
  • Restore โ€” replaces all editor state from the snapshot
  • Delete โ€” removes a single snapshot
  • Clear All โ€” wipes all snapshots for the post

Restore Behavior

When a Malet Owner restores a version:

  1. Store state is updated (title, html, json, excerpt, tags, coverImage)
  2. The TiptapEditor's setJSON() method is called with the parsed ProseMirror JSON
  3. If JSON parsing fails, falls back to setContent() with the HTML string
  4. The form becomes dirty (unsaved indicator appears)

Publish Approval Pipeline

A visual state machine governing the lifecycle of a blog post.

Stage Definitions

Stage Label Color Description
DRAFT Draft Gray (#6b7280) Work in progress, not visible to Visitors
REVIEW In Review Amber (#f59e0b) Submitted for team review
APPROVED Approved Green (#10b981) Reviewed and approved for publication
PUBLISHED Published Indigo (#667eea) Live on the Malet storefront
SCHEDULED Scheduled Purple (#8b5cf6) Queued for publication at a future date/time

Valid Transitions

DRAFT โ”€โ”€โ”€โ”€โ†’ REVIEW โ”€โ”€โ”€โ”€โ†’ APPROVED โ”€โ”€โ”€โ”€โ†’ PUBLISHED
  โ”‚                         โ”‚
  โ”œโ”€โ”€โ”€โ”€โ†’ PUBLISHED          โ”œโ”€โ”€โ”€โ”€โ†’ SCHEDULED
  โ””โ”€โ”€โ”€โ”€โ†’ SCHEDULED          โ””โ”€โ”€โ”€โ”€โ†’ DRAFT (send back)

PUBLISHED โ”€โ”€โ†’ DRAFT (unpublish)
SCHEDULED โ”€โ”€โ†’ DRAFT | PUBLISHED

Anyone with write permissions on the Malet can transition between stages. The pipeline is enforced visually โ€” context-sensitive action buttons appear based on the current stage:

  • DRAFT: "Submit for Review", "Publish", "Schedule" buttons
  • REVIEW: "Approve & Publish" button
  • PUBLISHED: No additional actions (post is live)

IMPORTANT

The publish pipeline is currently a frontend-only workflow. The backend blogs subgraph stores the status field and accepts REVIEW and SCHEDULED values, but does not enforce role-based approval gates. Any user with write permissions can publish directly.

TiptapEditor Enhancements

The shared TiptapEditor.svelte component was enhanced for the blog editor:

Feature Prop/API Description
JSON callback onupdate({ html, json }) Emits both HTML and ProseMirror JSON on every change
Word count showWordCount prop Footer bar with live word and character counts
JSON restore setJSON(jsonObj) method Restores editor state from a ProseMirror JSON object

GraphQL Changes

scheduledPublishAt was added to the Blog entity type, CreateBlogInput, and UpdateBlogInput in src/lib/queries/blog.ts. The REVIEW status was added to all status union types.

File Map

File Purpose
src/stores/blogEditorStore.svelte.ts Store: auto-save, validation, pipeline state
src/lib/components/blog/BlogForm.svelte UI: full blog editor with pipeline
src/lib/components/editor/TiptapEditor.svelte Enhanced rich text editor
src/lib/queries/blog.ts GraphQL mutations with scheduledPublishAt
tests/blogEditorStore.test.ts 34 unit tests