Architecture
Overview
The collab-editor is a collaborative article editor built for Hugging Face Spaces. It runs as a single Docker container serving both the backend (Express + Hocuspocus) and the frontend (React + TipTap).
┌─────────────────────────────────────────────────────────┐
│ Docker Container (port 8080) │
│ │
│ ┌──────────────────┐ ┌────────────────────────────┐ │
│ │ Express Server │ │ Hocuspocus (Y.js collab) │ │
│ │ │ │ │ │
│ │ /api/* │ │ /collab (WebSocket) │ │
│ │ /published/* │ │ │ │
│ │ /editor │ │ │ │
│ │ / (published) │ │ │ │
│ └──────────────────┘ └────────────────────────────┘ │
│ │
│ ┌──────────────────┐ ┌────────────────────────────┐ │
│ │ Publisher │ │ Frontend (static) │ │
│ │ (HTML renderer) │ │ /editor -> SPA │ │
│ │ (PDF generator) │ │ React + TipTap │ │
│ └──────────────────┘ └────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Key directories
| Path | Description |
|---|---|
backend/src/server.ts |
Entry point: imports createApp(), starts listener, signal handlers |
backend/src/create-app.ts |
Express app factory: routes, Hocuspocus, WebSocket, middleware |
backend/src/publisher/ |
HTML rendering, PDF generation, bibliography formatting |
backend/src/publisher/html-renderer.ts |
Converts TipTap JSON to static HTML page |
backend/src/publisher/extensions.ts |
Server-side TipTap extensions (mirrors frontend) |
backend/src/shared/component-defs.ts |
Shared component definitions (single source of truth) |
backend/src/utils.ts |
Shared utilities: docPath(), sanitizeName(), injectable DATA_DIR |
backend/src/auth.ts |
OAuth flow, token extraction, user resolution |
backend/src/hf-storage.ts |
HF dataset sync (push/pull documents and assets) |
frontend/src/editor/ |
TipTap editor, toolbars, components |
frontend/src/editor/components/registry.ts |
Component registry (imports from shared defs) |
frontend/src/styles/ |
All CSS files |
Styling architecture
CSS layers
The project uses five CSS layers, loaded in this order by main.tsx:
Template foundation (
_variables.css,_reset.css,_base.css,_layout.css,_print.css,components/*)- Defines the article's visual identity (typography, grid, components)
- Uses CSS custom properties (
--text-color,--surface-bg,--primary-color, etc.) - Layout tokens centralize grid math (
--layout-toc-width,--layout-content-width,--layout-gap, breakpoints) - Shared between the editor preview and the published output
Editor chrome (
_ui.css)- Styles the editor UI: top-bar, sidebars, dialogs, chat panel, embed studio
- Uses
--ed-*custom properties for the dark editor theme - Only loaded in the editor, never in published output
Design tokens (
tokens.css)- Light/dark theming tokens for both editor and article
- Text, background, border, accent, code highlighting, danger, shadows
- Supports
data-themeattribute andprefers-color-schememedia query - Shared between editor and published output (injected by publisher)
Shared article styles (
article.css,toc.css)- Article content styles shared between editor preview and published output
article.cssincludes wrapper components (Note, Stack, Quote, Sidenote...), with editor-specific variants scoped by.editor-app
Editor-only styles (
styles/editor/*.css, 5 files)_layout.css: grid overrides (3-col symmetric, aligned with template), TOC drawer, responsive breakpoints (1100px collapse, 768px mobile)_chrome.css: ProseMirror editing visuals (placeholder, selection, cursors, comment marks, math editing)_block-tools.css: block handles (drag + add) and slash menu_panels.css: image upload card, footnote tooltip, citation panel_hero-editable.css: transparent click-to-edit inputs for FrontmatterHero
Publisher CSS (
_publisher.css)- Styles specific to the published static HTML page
- Theme toggle animations, wide/fullWidth breakout, sidenote float, lightbox, PDF link
- Only injected by the HTML renderer (backend), never loaded in the editor
No CSS-in-JS
The project does not use MUI, Emotion, or any CSS-in-JS library. All styling is done via:
- CSS custom properties for theming
- Vanilla CSS files with BEM-like class naming
Floating UIfor tooltip positioning (lightweight, no CSS-in-JS)
Color theming
- The article area uses
data-theme="light"/data-theme="dark"with CSS variable overrides - The editor chrome is always dark, using
--ed-*tokens - The primary accent color is controlled via
--primary-color(synced via Yjs settings)
HF Spaces constraints
Iframe embedding
When deployed as a HF Space, the app runs inside an iframe. This affects:
- Viewport width: the iframe is ~968px wide, not the full browser width
- No
target="_top"navigation: links open within the iframe unless usingtarget="_blank" - OAuth flow: the OAuth callback URL must match the Space URL
- CSP restrictions: sandboxed iframes may restrict certain APIs
Two-page architecture
| URL | What it serves |
|---|---|
/ |
Published article (static HTML) or login prompt |
/editor |
The SPA editor (requires authentication) |
This means:
- The published article is a completely standalone HTML file
- It does NOT load React or any JS framework
- The editor is a separate React SPA at
/editor
CSS cascade
Because the published HTML is self-contained (all CSS inlined in <style>), there are no CSS conflicts with the HF Spaces iframe CSS. The editor uses Vite's CSS pipeline.
Shared component registry
Component definitions (name, kind, fields, defaults) are defined once in backend/src/shared/component-defs.ts. This file is the single source of truth.
- Backend (
extensions.ts): importsSHARED_COMPONENT_DEFSto generate TipTap server extensions forgenerateHTML() - Frontend (
registry.ts): importsSHARED_COMPONENT_DEFSvia Vite alias#sharedand decorates each entry with UI metadata (icon, label, description, placeholders)
Adding a new component:
- Add the entry to
shared/component-defs.ts - Add UI metadata to
frontend/src/editor/components/registry.tsinUI_META - Add CSS for the published view to
frontend/src/styles/_publisher.css
Publisher pipeline
Y.Doc -> TiptapTransformer -> JSON -> generateHTML() -> postProcess() -> full HTML page
|
v
PDF (Playwright)
|
v
Upload to HF dataset
Post-processing (linkedom)
The postProcess() function uses linkedom for DOM manipulation instead of regex:
- Accordion
<div>-><details>/<summary> - Citation
<span>-><a>links with bibliography anchors - Bibliography placeholder -> formatted HTML with entry IDs
- Mermaid
<div>-><pre class="mermaid"> - HtmlEmbed
<div>-><iframe> - Footnotes -> superscript links + appended section
Preview endpoint
GET /api/preview/:docName renders the HTML without saving or uploading. Useful for testing the publisher pipeline.
Auth and security
- AI chat routes (
/api/chat,/api/embed-chat) are auth-guarded when OAuth is enabled - The
requireEditormiddleware checks cookie token and verifies write access - Published articles are served without auth (public)
Testing
Tests use Vitest + Supertest. Run with npm test from backend/.
| Test file | What it covers |
|---|---|
tests/publisher.test.ts |
Y.Doc extraction, HTML generation, post-processing, idempotency |
tests/html-renderer-snapshot.test.ts |
Snapshot tests for each postProcess transformation |
tests/security.test.ts |
XSS prevention in published HTML |
tests/css-resolution.test.ts |
@custom-media resolution |
tests/utils.test.ts |
Path sanitization utilities |
tests/auth.test.ts |
Token extraction, OAuth configuration |
tests/hf-storage.test.ts |
HF dataset storage configuration |
tests/persistence.test.ts |
Local file persistence, debounced save |
tests/api-routes.test.ts |
API route integration tests (publish, auth status) |