A fast, local-first, in-browser workbench for Markdown, Avro binary data (
.avro), and Avro schemas (.avsc), with optional cloud sharing for Markdown documents.
Schema Studio is a single-page, SSR-capable React application that bundles three utilities into one workbench:
.md, .html, or .pdf.The app is local-first: uploaded files are decoded and rendered client-side and never sent to a server. An optional Markdown sharing feature uses Supabase to persist shareable, password-protectable links.
null and deflate are supported).flowchart LR
subgraph Browser
UI[React UI · TanStack Router]
MD[Markdown Studio]
AVSC[AVSC Viewer]
AVRO[Avro Viewer]
W[Web Worker · avro-worker.ts]
IDB[(IndexedDB · chunks)]
LS[(localStorage · prefs & drafts)]
end
subgraph Server[SSR / Serverless]
SSR[TanStack Start entry · src/server.ts]
FN[Server Functions · createServerFn]
end
subgraph Supabase
AUTH[Auth]
DB[(Postgres · public.shares)]
RT[Realtime]
end
UI --> MD
UI --> AVSC
UI --> AVRO
AVRO --> W --> IDB
UI <-.persist prefs.-> LS
UI -->|share / list / update| FN
FN --> DB
FN --> AUTH
UI --> SSR
UI <-.subscribe.-> RT
| Layer | Technology |
|---|---|
| Framework | TanStack Start (React + SSR + server fns) |
| Router | TanStack Router (file-based routes) |
| Data | TanStack Query |
| UI kit | shadcn/ui (Radix Primitives) + Tailwind CSS v4 + tw-animate-css |
| Icons | lucide-react |
| Markdown | marked, dompurify |
| Diagrams | mermaid |
jspdf + html2canvas |
|
| Forms | react-hook-form + zod + @hookform/resolvers |
| Toasts | sonner |
| Auth / DB | @supabase/supabase-js, @lovable.dev/cloud-auth-js |
| Build | Vite 8, nitro (Cloudflare Worker target), TypeScript 5 |
| Lint / format | ESLint 9 + typescript-eslint, Prettier |
schema-studio-main/
├── src/
│ ├── router.tsx # Router bootstrap (QueryClient injection)
│ ├── routeTree.gen.ts # Auto-generated route tree (do not edit)
│ ├── server.ts # Nitro/SSR entry with h3 error normalization
│ ├── start.ts # TanStack Start bootstrap (global fn middleware)
│ ├── styles.css # Tailwind entry
│ ├── components/
│ │ ├── app-sidebar.tsx # Left navigation
│ │ ├── studios.tsx # Markdown + AVSC + Avro data studios
│ │ ├── JsonTree.tsx # Recursive JSON tree + record-array table view
│ │ ├── share-dialog.tsx # Share creation UI
│ │ └── ui/* # shadcn/ui primitives
│ ├── hooks/
│ │ ├── use-auth.tsx # Supabase auth context
│ │ ├── use-mobile.tsx
│ │ └── use-theme.tsx # light / dark / system theme
│ ├── integrations/
│ │ ├── lovable/index.ts # OAuth (Google) via Lovable Cloud
│ │ └── supabase/
│ │ ├── client.ts # Browser client (proxy-lazy)
│ │ ├── client.server.ts
│ │ ├── auth-middleware.ts # `requireSupabaseAuth` server middleware
│ │ ├── auth-attacher.ts # Client fn middleware attaching bearer
│ │ └── types.ts # Generated DB types
│ ├── lib/
│ │ ├── avro.ts # Pure Avro OCF decoder
│ │ ├── avro-worker.ts # Web Worker wrapping the streaming decoder
│ │ ├── avro-store.ts # IndexedDB chunk store (paging)
│ │ ├── share.functions.ts # Server fns for share CRUD
│ │ ├── error-capture.ts # SSR error capture ring
│ │ ├── error-page.ts # Fallback SSR error HTML
│ │ ├── lovable-error-reporting.ts
│ │ └── utils.ts # cn() etc.
│ └── routes/
│ ├── __root.tsx # App shell (sidebar, header, footer, providers)
│ ├── index.tsx # `/` — Utilities hub
│ ├── markdown.tsx # `/markdown`
│ ├── avsc-viewer.tsx # `/avsc-viewer`
│ ├── avro-viewer.tsx # `/avro-viewer`
│ ├── auth.tsx # `/auth`
│ ├── s.$shareId.tsx # `/s/:shareId` — public shared markdown
│ └── _authenticated/
│ ├── route.tsx # Layout guard (redirects to /auth)
│ └── my-shares.tsx # `/my-shares` — manage shares
├── supabase/
│ ├── config.toml
│ └── migrations/… # `public.shares` schema + RLS
├── package.json
├── vite.config.ts
├── tsconfig.json
├── eslint.config.js
├── components.json # shadcn/ui config
├── bunfig.toml
└── README.md
Routing is file-based. src/routeTree.gen.ts is generated by the TanStack Router plugin.
| File | URL | Description |
|---|---|---|
routes/__root.tsx |
— | App shell (sidebar, header, footer, providers, error/404) |
routes/index.tsx |
/ |
Utilities hub with 3 utility cards |
routes/markdown.tsx |
/markdown |
Markdown editor |
routes/avsc-viewer.tsx |
/avsc-viewer |
Avro schema inspector |
routes/avro-viewer.tsx |
/avro-viewer |
Avro binary data reader |
routes/auth.tsx |
/auth |
Sign in / sign up (email + Google OAuth) |
routes/s.$shareId.tsx |
/s/:shareId |
Public shared markdown view/edit |
routes/_authenticated/route.tsx |
/_authenticated |
Guard: beforeLoad redirects if not signed in |
routes/_authenticated/my-shares.tsx |
/my-shares |
Owner-only share management |
Each route sets its own SEO/OG <meta> tags via head(). The /s/:shareId route is marked noindex.
src/routes/__root.tsx wraps every page in:
QueryClientProvider (React Query)ThemeProvider (light/dark/system)AuthProvider (Supabase session)<Toaster> (Sonner, top-right, richColors)SidebarProvider + AppSidebar (offcanvas collapsible)SidebarTrigger, centered brand link, “Local · No upload to server” badge, upload menu (AddFileMenu), theme toggle<Outlet /> inside a flex column <main>Global upload event bus — AddFileMenu in the header dispatches a ss:trigger-upload CustomEvent after routing to the matching page. The target studio subscribes via useUploadListener(kind) and opens its hidden <input type="file">.
Pre-hydration theme script — __root.tsx injects an inline <script> that reads localStorage["forge-theme"] and applies .light/.dark to <html> before React mounts to avoid FOUC.
Session reset on mount — useAvroSessionReset clears prior AVRO/AVSC localStorage keys and deletes the IndexedDB database on shell mount so datasets never leak across sessions.
Files: src/components/studios.tsx → MarkdownStudio, src/routes/markdown.tsx.
Textarea ──> source (usePersistedState "ss-md-source")
│
▼
marked.parse (sync) ──> DOMPurify.sanitize ──> html
│
▼
previewRef.innerHTML = html ──> renderMermaidIn(container, theme)
ResizablePanelGroup (default 50/50).DOMPurify.sanitize(raw, { ADD_TAGS: ["foreignObject"] }).renderMermaidIn scans pre > code.language-mermaid, calls mermaid.render, and swaps in the SVG. Theme (default / dark) is passed through.PanelCard fullscreen fixed to inset-0 z-50; only the preview stays visible..md, .markdown, .txt.buildPrintableDoc wraps preview HTML in a print CSS envelope, PRINT_CSS).html2canvas @ scale 2 → paginated jsPDF A4).<ShareButton> opens the share dialog, requiring sign-in.ss-md-source — current sourcess-md-filename — current filenameFiles: src/components/studios.tsx → AvroSchemaStudio, src/routes/avsc-viewer.tsx.
compileAvro)JSON.parse(text) → error surfaced if invalid.type === "record".name must be a non-empty string.fields must be an array; every field must have name and type.Returns either:
{ ok: true, schema, summary, fields } — where summary = record ${namespace}.${name} · N field(s).{ ok: false, error }.Badge, summary line, and a fields table (name, type, default). Types formatted by typeLabel() which handles primitives, unions, array<T>, map<T>, enum{...}, and nested records.<pre> when parsing succeeds..avsc (as typed)..avsc (only offered when JSON is valid).ss-avsc-source, ss-avsc-filename.Files: src/components/studios.tsx → AvroDataStudio, src/routes/avro-viewer.tsx.
sequenceDiagram
participant U as User
participant UI as AvroDataStudio
participant W as "AvroWorker (Web Worker)"
participant IDB as "IndexedDB (chunks store)"
U->>UI: Choose .avro file
UI->>IDB: resetAvroStore
UI->>W: postMessage decode + transferred buffer
W-->>UI: schema + codec + columns
loop per OCF block
W-->>UI: batch of rows
UI->>UI: buffer rows and enqueue CHUNK_SIZE slice
UI->>IDB: putChunks batch (coalesced writes)
W-->>UI: progress decoded count
end
W-->>UI: done
UI->>IDB: getChunk 0
IDB-->>UI: pageRows
UI-->>U: render Records table (page 1)
Records, Schema fields, Columns, Codec. Records/fields/columns are recomputed by effectiveStats: if the file has a single wrapper record with one array-of-records field (plus scalar metadata like totalRecordCount), the stats reflect the inner dataset (fixes the common “Records: 1” wrap pattern).null values italicized/muted.<JsonTree> (default depth 1).status-like columns rendered as color-coded <StatusPill> (green/rose/amber/muted).« First, ‹ Prev, Next ›, Last » with page X / Y and row range rows a–b of total.humanizeValue)Applies only to non-object cells whose column name matches heuristics:
timestamp|_ts$|_at$|time — numeric values are inferred as ns / µs / ms / s by magnitude and rendered as ISO strings with a raw: … · unit sub-label.date$ — small non-negative integers are treated as days since epoch.When meta.total === 1, the records pane switches to <RecordView> — record-array fields become titled sub-tables (rendered via <JsonTree> with table detection), and scalar fields appear as key: value lines below.
Blob and download link.forEachChunk, using toCsv/toCsvRows for RFC-4180-ish quoting.ss-avro-humanize — humanize toggle (persists across sessions).File: src/lib/avro.ts.
Pure TypeScript, dependency-free. Supports Avro Object Container File (OCF) with:
null, boolean, int, long, float, double, bytes, string.record, enum, array, map, union, fixed.Map<string, Schema>; both name and namespace.name are registered.null and deflate (via DecompressionStream("deflate-raw") piped through a Blob stream).decimal on bytes/fixed — decoded via decimalFromBytes (two’s-complement big-endian → BigInt → scaled string).Reader class holds a Uint8Array + DataView + position.readLong).DataView little-endian.0x4F 0x62 0x6A 0x01.avro.schema and optional avro.codec.objectCount, blockSize, blockBytes, and ends with a sync marker verified byte-by-byte.decodeAvroContainerStreaming(bytes, onSchema, onBlock) emits the schema exactly once, then invokes onBlock(records) per block. Used by the Web Worker to keep memory bounded.
flattenRecordsToRows — pivots array-of-records to {columns, rows}.deriveColumnsFromSchema — extracts top-level record field names.normalizeCell — hex-encodes Uint8Array, string-ifies bigint, deep-normalizes nested objects/arrays.toCsv / toCsvRows — RFC-4180-ish quoting (" doubled, wrap when , / " / newline present).src/lib/avro-worker.ts)main → { type: "decode", buffer } (transferred ArrayBuffer)
worker → { type: "schema", schema, codec, columns }
↺ { type: "batch", rows, startIndex }
↺ { type: "progress", decoded }
worker → { type: "done", total } | { type: "error", message }
normalizeCell so the main thread never sees Uint8Array or bigint.src/lib/avro-store.ts)schema-studio-avro, object store chunks, keyed by chunk index.CHUNK_SIZE = 1000 rows per chunk.IDBDatabase is kept open for the tab lifetime; measurable perf win on multi-million-row ingests (each open+close previously cost milliseconds per 1000 rows).putChunks(entries) writes many chunks in one readwrite transaction. The Studio buffers worker batches into CHUNK_SIZE slices, queues them, and drains them serially so writes never overlap.getChunk(index) — O(1) paging.forEachChunk(cb) — cursor iteration for exports.resetAvroStore() — closes the DB and deletes it; called on shell mount and before every new import.The Studio’s drain() runs at most one IDB transaction at a time; new batches queue while a drain is in flight. This keeps memory bounded even if the worker outpaces IDB throughput.
Applies only to Markdown documents.
public.shares| Column | Type | Notes |
|---|---|---|
id |
UUID PK | gen_random_uuid() |
share_id |
TEXT unique | 9-char lowercase base-32-ish (abcdefghijkmnopqrstuvwxyz23456789) |
owner_id |
UUID | FK → auth.users(id) |
title |
TEXT | default Untitled |
content |
TEXT | Markdown source |
permission |
TEXT | 'view' \| 'edit' (CHECK) |
password_hash |
TEXT | nullable |
created_at, updated_at |
TIMESTAMPTZ | trigger auto-updates updated_at |
RLS — SELECT/INSERT/UPDATE/DELETE all gated by auth.uid() = owner_id. Public reads use a server-side REST call with the service/publishable key so unauthenticated visitors can read the row without RLS bypass in the client. Realtime publication includes shares with REPLICA IDENTITY FULL.
src/lib/share.functions.ts)| Function | Method | Auth | Purpose |
|---|---|---|---|
createShare |
POST | ✅ | Insert new row with retry on 23505 (unique collision) |
listMyShares |
GET | ✅ | List all owner shares |
updateShareSettings |
POST | ✅ | Change title/permission/password (null removes, undefined keeps) |
deleteShare |
POST | ✅ | Delete by share_id scoped by owner_id |
getShareMeta |
GET | ❌ | Returns metadata only; no content |
getShareContent |
POST | ❌ | Returns content, or password_required / wrong_password / not_found |
updateSharedContent |
POST | ❌ | Public-writer path for edit permission; requires correct password if set |
Authenticated fns compose requireSupabaseAuth middleware; unauthenticated fns hit Supabase REST directly using env keys (SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY / SUPABASE_PUBLISHABLE_KEY / etc.).
pbkdf2$120000$<base64 salt>$<base64 derived>.iterations = 120_000, hash = SHA-256, 32-byte derived key, 16-byte random salt.verifyPassword uses constant-time compare on base64 strings.ShareButton + ShareDialog)/auth?next=....view / edit) + optional password.${origin}/s/${shareId} and points user to /my-shares.routes/s.$shareId.tsx)getShareContent; if password_required, shows a locked card.SharedEditor:
Save now button.postgres_changes on the row via supabase.channel(share:${id}). Remote updates refresh local state only when !dirty.SHARE_PRINT_CSS envelope.<meta name="robots" content="noindex">.routes/_authenticated/my-shares.tsx)Editable / Password, updated timestamp, URL).confirm).src/routes/auth.tsx — a single card with:
lovable.auth.signInWithOAuth("google", { redirect_uri: origin + "/auth" }).supabase.auth.signInWithPassword / signUp. Sign-up uses emailRedirectTo: origin + "/auth" and a minimum 6-character password.next (if it starts with /) or /.src/hooks/use-auth.tsx — AuthProvider subscribes to supabase.auth.onAuthStateChange and hydrates via getSession().
src/integrations/supabase/auth-attacher.ts — a client-side functionMiddleware that grabs the current access token from supabase.auth.getSession() and attaches it as Authorization: Bearer <jwt>. Registered globally in src/start.ts (see AGENTS.md note).src/integrations/supabase/auth-middleware.ts — server-side requireSupabaseAuth. Validates the bearer token with supabase.auth.getClaims(token), requires data.claims.sub, and injects { supabase, userId, claims } into the server-fn context. It also normalizes the “new Supabase API keys” case (sb_publishable_ / sb_secret_) which are opaque, not JWTs.| Layer | Purpose | Scope |
|---|---|---|
localStorage (usePersistedState) |
Markdown source & filename, AVSC source & filename, humanize toggle, forge-theme |
Persists across sessions |
localStorage reset on mount |
Clears ss-avsc-*, ss-avro-mode, ss-avro-humanize, ss-section |
Per shell mount |
IndexedDB (schema-studio-avro) |
Avro row chunks (paging) | Reset on shell mount and per import |
| Supabase Postgres | Shared markdown documents | Owner-only, with public read via server fn |
ThemeProvider (src/hooks/use-theme.tsx) stores light | dark | system in localStorage.forge-theme..light / .dark to <html>; subscribes to the (prefers-color-scheme: dark) media query when in system mode.ThemeToggle) flips between light and dark by resolving the current effective theme first.Tailwind v4 is used via the shadcn/ui component set. Utility patterns like bg-gradient-to-br from-primary/20 to-primary/5 drive the stat cards.
src/server.ts wraps @tanstack/react-start/server-entry with:
consumeLastCapturedError).{"unhandled":true,"message":"HTTPError"}. normalizeCatastrophicSsrResponse detects that shape on 5xx JSON responses and re-renders a friendly HTML error page (renderErrorPage) instead.vite.config.ts uses @lovable.dev/vite-tanstack-config, which auto-registers TanStack Start, React, Tailwind v4, tsconfig-paths, and Nitro with a Cloudflare Worker target.DOMPurify before insertion into the preview panels (with foreignObject allowed for Mermaid).securityLevel: "loose" is set on Mermaid because the diagram source is user-authored client-side; input never crosses trust boundaries.verifyPassword uses a constant-time comparison.noindex meta on shared pages prevents accidental search-engine indexing.postMessage payloads are cheap-to-clone JS values.Array.prototype.push.apply(buffer, rows) avoids spreading huge arrays onto the call stack (...rows would overflow at ~65K args in some engines).openDB memoizes the promise; measurable win vs per-write open.CHUNK_SIZE) rows; the DOM never sees the full dataset.left:-99999px so layout is real but invisible; html2canvas @ scale 2, then A4 pagination.__root.tsx → ErrorComponent) shows a friendly “This page didn’t load” page, reports via reportLovableError, and provides Try again / Go home.NotFoundComponent matches the same visual language.sonner) for user-visible success/error paths (decode, share create/update/delete, exports).src/lib/lovable-error-reporting.ts forwards runtime errors to the Lovable Cloud sink (reportLovableError).renderErrorPage() produces a self-contained HTML page returned when h3 returns 5xx JSON.Required environment variables:
| Variable | Purpose |
|---|---|
VITE_SUPABASE_URL / SUPABASE_URL |
Supabase project URL |
VITE_SUPABASE_PUBLISHABLE_KEY / SUPABASE_PUBLISHABLE_KEY |
Client-safe API key |
SUPABASE_SERVICE_ROLE_KEY (or fallbacks) |
Used by public-read server fns for share content |
vite.config.ts delegates env injection to @lovable.dev/vite-tanstack-config (auto-injects VITE_*).
Package scripts:
| Script | Purpose |
|---|---|
npm run dev |
Vite dev server (SSR + HMR) |
npm run build |
Production build via Vite + Nitro |
npm run build:dev |
Development-mode build |
npm run preview |
Preview built site |
npm run lint |
ESLint |
npm run format |
Prettier |
Target runtime — Nitro builds a Cloudflare Worker (workerd-compatible). The share-functions module explicitly uses WebCrypto (crypto.subtle) so it runs unmodified on workerd.
Lovable integration — the repo is connected to Lovable; commits pushed to the connected branch sync back into the Lovable editor. See AGENTS.md.
File: supabase/migrations/20260723170117_d547e1ef-bfac-4bc9-8af0-65d13ee54df5.sql.
CREATE TABLE public.shares (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
share_id TEXT UNIQUE NOT NULL,
owner_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT 'Untitled',
content TEXT NOT NULL DEFAULT '',
permission TEXT NOT NULL DEFAULT 'view' CHECK (permission IN ('view','edit')),
password_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Highlights:
shares_owner_id_idx, shares_share_id_idx indexes.auth.uid() = owner_id.shares_touch_updated_at trigger keeps updated_at fresh.supabase_realtime publication with REPLICA IDENTITY FULL so remote editors see full-row updates.codec !== "null" branch in decodeAvroContainer{,Streaming} with a matching decompressor (e.g. snappy via a WASM binding).src/routes/, export a component, and wire it into AppSidebar.items and the utility hub grid.ExportAction[] entries to PreviewHeader; use askAndDownload(defaultName, mime, produce) for consistent filename prompts.permission CHECK constraint, update SharePatch types, and extend the radio groups in share-dialog.tsx and my-shares.tsx.src/routes/ are auto-picked up by the TanStack Router plugin, which regenerates routeTree.gen.ts.Crafted with ♥ by Sanidhya Dash.