v0.6.5: Renderer pipeline, docs rewrite, architecture diagrams
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-go-pg (pull_request) Successful in 2m43s
CI/CD / test-sqlite (pull_request) Successful in 2m51s
CI/CD / build-and-deploy (pull_request) Successful in 1m13s

Lift block rendering to kernel SDK primitives (sw.renderers + sw.markdown)
so all surfaces share one markdown pipeline. Rewrite docs for external
audience — remove all fork history references. Add Mermaid architecture
diagrams, CONTRIBUTING guide, and extension tutorial.

- sw.renderers SDK module: kernel-level renderer registry
- sw.markdown SDK module: unified marked v16 + DOMPurify pipeline
- Browser extension script loader for renderer injection
- Notes + Docs surfaces migrated to sw.markdown.renderSync()
- 4 renderer extensions rewritten to IIFE + sw.renderers.register()
- 6 Mermaid diagrams in ARCHITECTURE.md
- CONTRIBUTING.md + TUTORIAL-FIRST-EXTENSION.md
- DESIGN-WORKFLOWS.md replaces fork-era design doc
- Surface alias routes removed from main.go
- ICD/SDK runners migrated to /admin/packages/ endpoints
- 13 new renderer tests
- Docs, CHANGELOG, ROADMAP cleaned of fork references

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 15:26:44 +00:00
parent 36d6158940
commit 2adaabe5fa
32 changed files with 1769 additions and 1974 deletions

View File

@@ -2,6 +2,62 @@
All notable changes to Switchboard Core are documented here.
## v0.6.5 — Renderer Pipeline + Docs Rewrite
Lifts block rendering to a kernel SDK primitive so all surfaces share one
markdown pipeline. Rewrites docs for external audience. Adds CONTRIBUTING
guide and extension tutorial.
### Added
- **`sw.renderers` SDK module**: Kernel-level renderer registry. Extensions
register block renderers (fenced code blocks) and post renderers (DOM
post-processing) once; all surfaces consume via `sw.markdown`.
- **`sw.markdown` SDK module**: Unified markdown rendering via `marked` v16
(vendored). Lazy-loads `marked` + DOMPurify. Custom code hook delegates
fenced blocks to `sw.renderers`. Wikilink tokenizer (`[[Page Name]]`)
built in for all surfaces.
- **Browser extension script loader**: Server injects `<script>` tags for
enabled browser-tier extensions into all pages. Extensions register via
`sw:ready` event.
- **Chat markdown support**: Messages with `content_type: 'markdown'` render
via `sw.markdown`. Existing `text` messages stay plain (no behavior change).
- **Mermaid architecture diagrams**: Six diagrams in `docs/ARCHITECTURE.md`:
system overview, request flow, extension lifecycle, realtime events,
settings cascade, cluster topology.
- **`CONTRIBUTING.md`**: Development setup, project structure, test commands,
code conventions.
- **`docs/TUTORIAL-FIRST-EXTENSION.md`**: Step-by-step guide to building and
installing a browser extension.
- **`docs/DESIGN-WORKFLOWS.md`**: Clean workflow system design document.
- **13 new renderers tests** (`src/js/__tests__/renderers.test.js`).
### Changed
- **Notes surface**: Hand-rolled markdown renderer replaced with
`sw.markdown.renderSync()`. Post-renderers (mermaid, katex, etc.) now
execute on preview. ~90 lines deleted.
- **Docs surface**: Hand-rolled markdown renderer replaced with
`sw.markdown.renderSync()`. ~160 lines deleted.
- **4 renderer extensions** (mermaid, katex, csv-table, diff-viewer):
Rewritten from dead `Extensions.register()` pattern to IIFE +
`sw.renderers.register()` via `sw:ready` event. All rendering logic
preserved.
- **Surface alias routes removed**: Six `/api/v1/admin/surfaces/*` backward
compat routes deleted from `main.go`. SDK and ICD runner migrated to
canonical `/api/v1/admin/packages/*` endpoints.
- **Docs rewritten for external audience**: All references to fork history,
internal codenames, and pre-fork version numbers removed from docs,
CHANGELOG, and ROADMAP.
### Removed
- `docs/DESIGN-WORKFLOW-REDESIGN-0.2.6.md` — replaced by `DESIGN-WORKFLOWS.md`.
- Hand-rolled markdown renderers in Notes and Docs surfaces.
- Six `/admin/surfaces/*` API alias routes.
---
## v0.6.4 — Admin Health/Metrics Tab + Cluster Merge
Structural consolidation: cluster dashboard merges into Admin as a Health tab.
@@ -48,7 +104,7 @@ Comprehensive metrics endpoint for runtime, DB, cluster, and extension stats.
## v0.6.3 — Dead Code Sweep + Registry Fix
Pre-fork hardening: fix broken registry install, add registry settings UI,
Hardening: fix broken registry install, add registry settings UI,
clean up dead code, narrow default bundle.
### Fixed
@@ -74,10 +130,10 @@ clean up dead code, narrow default bundle.
unused vendor copies with zero production imports. Removed cache entries
from `sw.js`.
- **`dev.html`**: 676-line standalone dev gallery, not referenced by anything.
- **Pre-fork version comments**: Stripped all chat-switchboard version
annotations across ~140 files — standalone comments, inline suffixes,
section headers, and config field docs. Reworded to describe features
rather than reference pre-fork version numbers.
- **Stale version comments**: Stripped legacy version annotations across
~140 files — standalone comments, inline suffixes, section headers, and
config field docs. Reworded to describe features rather than reference
obsolete internal version numbers.
### Changed
@@ -1040,8 +1096,8 @@ Indexes on `folder_id`, `creator_id`, `pinned`, `updated_at`.
- **Dead `history_mode` in stage UI**: Removed history mode selector and state
from StageForm. Backend column retained for v0.3.x schema migration.
- **Dead ICD tests**: Removed assertions for `/teams/:teamId/personas`,
`/teams/:teamId/providers`, and `/teams/:teamId/models` — endpoints were
removed in Phase 0 fork.
`/teams/:teamId/providers`, and `/teams/:teamId/models` — endpoints no
longer exist in the kernel.
- **Stale `chat_only` stage mode**: Frontend `STAGE_MODES` updated from
`['chat_only', 'form_only', 'form_chat', 'review']` to
`['form_only', 'form_chat', 'review', 'custom']` matching the backend
@@ -1135,13 +1191,13 @@ Indexes on `folder_id`, `creator_id`, `pinned`, `updated_at`.
(v0.2.7 admin, v0.2.8 user, v0.2.9 team-admin pass 1).
- Updated bus doc examples and test labels from `chat.*` to `workflow.*`.
- Renamed `channel-` prefixed test paths to `test-` in storage tests.
- Updated doc comments throughout to remove references to gutted surfaces.
- Updated doc comments throughout to reflect current surface architecture.
### Removed
- **~500 lines of dead CSS**: Orphaned classes for gutted chat, channel,
project, notes, editor-chat, sidebar, and router-picker features from
`layout.css` and `surfaces.css`.
- **~500 lines of dead CSS**: Orphaned classes for removed legacy UI
components (chat, channel, project, notes, editor-chat, sidebar,
router-picker) from `layout.css` and `surfaces.css`.
- **Dead Go types**: `Grant` struct (persona-era), `CompositeModelKey` func,
comment-only stubs for NoteGraph, ProjectChannel, etc.
- **Dead event code**: `chat.typing.*` / `channel.typing.*` condition in WS
@@ -1177,7 +1233,7 @@ Indexes on `folder_id`, `creator_id`, `pinned`, `updated_at`.
### Changed
- **UserMenu**: Surface list now driven entirely by the `/api/v1/surfaces`
API. Removed hardcoded Chat, Notes, Projects links (gutted in Phase 0).
API. Removed hardcoded surface links; menu is now fully dynamic.
Core surfaces (Admin, Settings, Team Admin, Workflow) filtered from the
API list and handled as dedicated menu items with RBAC gating.
- **Tasks surface**: Replaced custom `.tasks-header` with `sw.shell.Topbar`.
@@ -1328,7 +1384,8 @@ Indexes on `folder_id`, `creator_id`, `pinned`, `updated_at`.
## [v0.1.0] — 2026-03-26
Forked from chat-switchboard v0.38.5. Gutted to a pure extension platform.
Initial release. Pure extension platform — kernel provides auth, RBAC,
storage, and the Starlark sandbox. Everything else is a package.
### Removed
@@ -1364,7 +1421,7 @@ Forked from chat-switchboard v0.38.5. Gutted to a pure extension platform.
### Changed
- Module renamed: `chat-switchboard` `switchboard-core`
- Go module: `switchboard-core`
- VERSION: `0.1.0`
- Default DB name: `switchboard_core`
- Fresh migrations: 9 files × 2 dialects (postgres + sqlite), 27 tables

64
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,64 @@
# Contributing to Switchboard Core
## Development Setup
**Requirements:** Go 1.23+, Node.js 20+ (for frontend tests), SQLite (local dev).
**Build from source:**
```sh
cd server
go build -o switchboard-core .
DB_DRIVER=sqlite DATABASE_URL=/tmp/switchboard.db ./switchboard-core
```
**Docker (recommended):**
```sh
docker compose up --build
# open http://localhost:3000 (default login: admin / admin)
```
Data persists in the `sb_data` named volume. Reset with `docker compose down -v`.
## Project Structure
```
server/ Go backend (handlers, store, config, auth, workflow engine)
src/js/ Browser SDK and built-in surface JS
packages/ Extension packages (surfaces, libraries, browser extensions)
build.sh Builds each subdirectory into a .pkg archive
docs/ Documentation markdown (served at /api/v1/docs)
k8s/ Kubernetes manifests and Helm chart
```
## Running Tests
**Backend:** `cd server && go test ./...`
**Frontend:** `node --test src/js/__tests__/`
## Code Conventions
- **Go:** Standard formatting via `gofmt`. Handlers in `server/handlers/`,
persistence in `server/store/`. Database access goes through the store
interface, never directly from handlers.
- **Browser JS:** Vanilla ES modules + Preact via CDN. No build step for
browser code (except the CM6 editor bundle). SDK lives at `src/js/sw/`.
- **Extensions:** IIFE pattern (see `packages/*/js/script.js`). Register with
`sw.renderers` or `sw.slots` via the `sw:ready` event.
## Creating a Package
A package is a ZIP archive (`.pkg`) containing `manifest.json` and optional
`js/`, `css/`, `assets/`, and `script.star` files. The build script
(`packages/build.sh`) automates this. See `docs/PACKAGE-FORMAT.md` for the
full manifest spec and `docs/TUTORIAL-FIRST-EXTENSION.md` for a walkthrough.
## Pull Request Process
1. Create a feature branch from `main`.
2. Make your changes. Keep commits focused.
3. Run both Go and JS test suites and confirm they pass.
4. Submit a PR with a clear description of what changed and why.
5. Address review feedback, then squash-merge when approved.

View File

@@ -1,6 +1,6 @@
# Switchboard Core — Roadmap
## Current: v0.6.4Admin Health/Metrics Tab + Cluster Merge
## Current: v0.6.5Renderer Pipeline + Docs Rewrite
Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox,
storage, realtime, and ops are kernel primitives. Everything else is an extension.
@@ -71,9 +71,9 @@ PG is the consensus layer. Zero new infrastructure. `UNLOGGED` table + `LISTEN/N
---
## v0.6.x — Pre-Fork Hardening
## v0.6.x — Hardening
Closes every audit finding before the public fork. No new features — only correctness, dead code elimination, and architectural cleanup. Sequence is fixed: each version is a gate for the next.
Closes every audit finding before the public release. No new features — only correctness, dead code elimination, and architectural cleanup. Sequence is fixed: each version is a gate for the next.
### v0.6.3 — Dead Code Sweep + Registry Fix
@@ -89,7 +89,7 @@ Pure cleanup. No behavior changes except fixing the broken registry install flow
| Delete `dev.html` | ✅ | 676 lines, not imported by anything. Dead. |
| Remove `dashboard` from default bundle | ✅ | Requires `legacy-sdk` (doesn't exist), auto-installs as dormant on fresh installs. Confusing. Remove from `defaultBundledPackages`. |
| Remove `hello-dashboard` | ✅ | Proof-of-concept from early development. Move to `examples/` or delete. |
| Strip stale version comments | ✅ | 60+ files have `// v0.29.x:`, `// v0.33.x:` pre-fork comments. Single sed pass. |
| Strip stale version comments | ✅ | 60+ files had legacy version annotations (`// v0.29.x:`, `// v0.33.x:`). Single sed pass. |
| Narrow default bundle | ✅ | Default: `notes`, `chat`, `chat-core`, `mermaid-renderer`, `schedules`. Everything else available via registry or `BUNDLED_PACKAGES=*`. |
### v0.6.4 — Admin Health/Metrics Tab + Cluster Merge
@@ -114,20 +114,20 @@ Most complex sub-version. Lifts block rendering to a kernel SDK primitive so all
| Step | Status | Description |
|------|--------|-------------|
| SDK renderer primitive | | `sw.renderers.register(pattern, handler)` — kernel-level registration. Extensions call once; all surfaces consume. Decouples renderer discovery from chat surface. |
| Notes hooks SDK renderer pipeline | | Notes `renderMarkdown()` delegates fenced blocks to registered renderers instead of emitting raw `<pre>`. |
| Docs hooks SDK renderer pipeline | | Docs markdown renderer delegates fenced blocks to registered renderers. |
| Unify markdown renderer | | Three separate markdown implementations (Notes hand-rolled, Docs hand-rolled, Chat `marked`). Adopt `marked` (already vendored) across all three surfaces. Delete hand-rolled duplicates. |
| Sanitize HTML output | | DOMPurify is vendored but unused. Wire it as post-render step for user-generated markdown (Notes). Closes XSS surface. |
| Mermaid/KaTeX/CSV/Diff work everywhere | | With `requires: ["chat"]` removed (v0.6.4) and renderer pipeline lifted, these render in notes + docs + chat without surface-specific code. |
| Docs rewrite for external audience | | Remove all references to "chat-switchboard", "the fork", "the gut", "Phase 0", "scorched earth". Reframe from "we removed X" to "the kernel provides Y". Remove pre-v0.2.0 version references. |
| Add Mermaid architecture diagrams | | Diagrams in docs: system architecture overview, request flow, extension lifecycle, realtime event flow, settings cascade, cluster topology. Serves double duty: good docs + proof mermaid-renderer works in docs surface. |
| Surface alias decision | | `main.go:819826` has six `/admin/surfaces/*` alias routes. Decision: keep permanently (document) or migrate SDK + ICD runner to `/admin/packages/` and delete. Ship one or the other, not both undocumented. |
| `CONTRIBUTING.md` + tutorial | | "Build your first extension" guide: manifest → Starlark → ext_data → API route → surface JS → install → test. Use `team-activity-log` as worked example. |
| SDK renderer primitive | | `sw.renderers.register(pattern, handler)` — kernel-level registration. Extensions call once; all surfaces consume. Decouples renderer discovery from chat surface. |
| Notes hooks SDK renderer pipeline | | Notes delegates to `sw.markdown.renderSync()` + `sw.renderers.runPostRenderers()`. Hand-rolled renderer deleted. |
| Docs hooks SDK renderer pipeline | | Docs delegates to `sw.markdown.renderSync()` + `sw.renderers.runPostRenderers()`. Hand-rolled renderer deleted (~160 lines). |
| Unify markdown renderer | | `sw.markdown` module lazy-loads `marked` v16 (vendored). Notes, Docs, Chat all consume it. Two hand-rolled parsers deleted. |
| Sanitize HTML output | | DOMPurify wired as default post-render step in `sw.markdown`. SVG-safe config allows mermaid output. Notes sanitizes; Docs opts out (system-authored). |
| Mermaid/KaTeX/CSV/Diff work everywhere | | Browser extension loader injects renderer scripts into all pages. Extensions register via `sw:ready` event. All four render in Notes, Docs, and Chat. |
| Docs rewrite for external audience | | All fork references removed from docs, CHANGELOG, ROADMAP. DESIGN-WORKFLOW-REDESIGN-0.2.6.md replaced with DESIGN-WORKFLOWS.md. |
| Add Mermaid architecture diagrams | | Six diagrams in ARCHITECTURE.md: system overview, request flow, extension lifecycle, realtime events, settings cascade, cluster topology. |
| Surface alias decision | | Migrated SDK + ICD runner to `/admin/packages/`; aliases removed from main.go. |
| `CONTRIBUTING.md` + tutorial | | CONTRIBUTING.md at repo root + docs/TUTORIAL-FIRST-EXTENSION.md walkthrough. |
### v0.6.6 — Pre-Fork Hardening
### v0.6.6 — Final Hardening
Final pass before the hard fork. Security, correctness, and developer experience.
Final pass before public release. Security, correctness, and developer experience.
| Step | Status | Description |
|------|--------|-------------|
@@ -139,7 +139,7 @@ Final pass before the hard fork. Security, correctness, and developer experience
| ICD/SDK runner update pass | ☐ | ICD runner and SDK runner are stale for cluster, backup, chat, realtime, dynamic OpenAPI. Update to cover current API surface. |
| Stale TODO resolution | ☐ | `main.go:867` (session middleware TODO, stale since v0.2.0). `auth.go:221` (OIDC nonce, covered above). `starlark_helpers.go:96` (migration stub, covered above). Resolve or delete. |
Then fork.
Then ship.
---
@@ -159,10 +159,10 @@ Then fork.
| Decision | Rationale |
|----------|-----------|
| Tasks → extension | Scheduler was the most entangled kernel component (~3,400 lines). Rebuilding as extension validates the trigger system and removes the worst compilation debt. Three trigger primitives (time, webhook, event) replace the monolithic scheduler. |
| Sessions removed | Channel-based sessions coupled to deleted chat system. Workflow instances need new storage model — either ext_data tables or a dedicated kernel table. |
| `chat_only``custom` | Stage mode `chat_only` implied chat as a kernel concept. Renamed to `custom` which delegates to a surface package, proving extension composability. |
| Sessions removed | Kernel-managed sessions replaced by workflow instances with dedicated storage (ext_data tables or kernel table). |
| `custom` stage mode | Stage mode `custom` delegates to a surface package, proving extension composability. Chat-in-workflow is handled by the chat extension, not the kernel. |
| Providers removed from kernel | Provider configs, model catalog, routing policies — all moved to extension track. Kernel provides credential storage (connections) and the Starlark `provider.complete` module as the interface. |
| Kernel permissions simplified | From 16 chat-centric permissions to 6 platform permissions. Extensions define their own capability requirements in manifests. |
| Kernel permissions simplified | 6 platform permissions. Extensions define their own capability requirements in manifests. |
| Preact+htm retained | 3KB runtime, no build step, works for extension authors without bundler config. KISS. |
| Single Docker image | Drop the frontend/backend split. Go binary + assets + migrations in one image. Simpler deployment, fewer moving parts. |
| Admin → RBAC group | The `role` column is pre-RBAC. v0.2.0 replaces it with a seeded "Admins" group + `surface.admin.access` grant. All users auto-join "Everyone" group. Admin middleware becomes a grant check, not a role check. |

View File

@@ -1 +1 @@
0.6.4
0.6.5

View File

@@ -25,6 +25,32 @@ package installer. Surfaces (UI pages), tools, filters, triggers, and
providers are all packages. The editor, chat, and admin UI will themselves
be installable surface packages.
## System Architecture Overview
```mermaid
graph TD
Browser["Browser (Preact + htm)"]
Server["Go Server (Gin)"]
DB["Database (SQLite / Postgres)"]
Browser -->|HTTP / WebSocket| Server
subgraph Server Layers
Handlers["HTTP Handlers"]
Sandbox["Starlark Sandbox"]
Store["Store Interface"]
EventBus["Event Bus"]
end
Server --> Handlers
Handlers --> Store
Handlers --> Sandbox
Sandbox --> Store
Handlers --> EventBus
Store --> DB
EventBus -->|SSE / WS| Browser
```
## Kernel Components
### Identity & Auth
@@ -36,6 +62,31 @@ permissions, settings, and data against.
Auth modes: `builtin` (password), `mtls` (client cert), `oidc` (Keycloak
et al.). Mode is set at deploy time via `AUTH_MODE` env var.
### Request Flow
```mermaid
sequenceDiagram
participant C as Client
participant CORS as CORS Middleware
participant Auth as Auth Middleware
participant H as Handler
participant S as Store
participant DB as Database
C->>CORS: HTTP Request
CORS->>Auth: Pass through
Auth->>Auth: Validate token / session
alt Unauthorized
Auth-->>C: 401 JSON error
end
Auth->>H: Authenticated context
H->>S: Store method call
S->>DB: SQL query
DB-->>S: Rows
S-->>H: Typed result
H-->>C: JSON response
```
### Teams & Groups
Teams provide horizontal isolation — users see only their team's resources.
@@ -63,6 +114,26 @@ The unified registry for all installable content. A package is a surface
Tiers: `browser` (JS only), `starlark` (sandboxed server-side),
`sidecar` (container, future).
#### Extension Lifecycle
```mermaid
graph TD
Upload["Package Upload (.pkg)"]
Parse["Manifest Parse"]
Insert["DB Insert (packages table)"]
Enable["Admin Enable Toggle"]
Mount["Surface Mount (/s/:slug)"]
SDK["SDK Boot"]
Render["Renderer Registration"]
Upload --> Parse
Parse --> Insert
Insert --> Enable
Enable --> Mount
Mount --> SDK
SDK --> Render
```
### Starlark Sandbox
Extensions declare capabilities in their manifest. The admin grants or
@@ -128,6 +199,27 @@ Server-sent events to WebSocket clients. Kernel prefixes:
Extensions will subscribe to event patterns at install time (v0.2.0).
Match expressions start as exact strings, grow to globs later.
#### Realtime Event Flow
```mermaid
sequenceDiagram
participant C as Client
participant WS as WebSocket Hub
participant PG as PG LISTEN/NOTIFY
participant Other as Other Node
C->>WS: WS connect + subscribe
Note over WS: Local fan-out to subscribers
WS-->>C: Event push
Other->>PG: NOTIFY channel, payload
PG->>WS: LISTEN callback
WS-->>C: Fan-out to local subscribers
WS->>PG: NOTIFY channel, payload
PG->>Other: LISTEN callback
```
### Storage & Notifications
**Object storage**: PVC (local disk) or S3-compatible. Used by extensions
@@ -156,6 +248,26 @@ SQLite parity rules: `boolToInt` for boolean binding, `store.NewID()` for
INSERT RETURNING, no `NULLS FIRST`, no boolean literals, no `$N` reuse,
`database.ST()`/`database.SNT()` wrappers for time scanning.
## Settings Cascade
```mermaid
graph TD
Global["Global Scope (admin)"]
Team["Team Override"]
User["User Override"]
RBAC{"RBAC Gate: who can set at what scope?"}
Flag{"user_overridable flag"}
Effective["Effective Value"]
Global --> RBAC
RBAC -->|allowed| Team
RBAC -->|denied| Effective
Team --> Flag
Flag -->|true| User
Flag -->|false| Effective
User --> Effective
```
## Frontend
Preact (3KB) + htm (tagged template literals). No build step, no bundler
@@ -174,3 +286,25 @@ with DaemonSet DinD runners testing both PG and SQLite pipelines.
Registry: `registry.gobha.me:5000/xcaliber/switchboard-core`
Namespace: `gobha-ai-chat`
### Cluster Topology
```mermaid
graph TD
N1["Node 1"]
N2["Node 2"]
N3["Node 3"]
PG["PG UNLOGGED Table (cluster_nodes)"]
Sweep["Stale Sweep Goroutine"]
Notify["PG LISTEN/NOTIFY"]
N1 -->|heartbeat INSERT/UPDATE| PG
N2 -->|heartbeat INSERT/UPDATE| PG
N3 -->|heartbeat INSERT/UPDATE| PG
Sweep -->|DELETE nodes not seen| PG
PG -->|join/leave events| Notify
Notify --> N1
Notify --> N2
Notify --> N3
```

View File

@@ -1,806 +0,0 @@
# Workflow Redesign — v0.2.6
**Status:** Phase A complete (v0.3.0 — schema + stage CRUD modernization)
**Branch:** `feat/workflow-redesign-v0.2.6`
**Date:** 2026-03-27
**Context:** Mapping viable ideas from chat-switchboard v0.39.x onto core's extension-first architecture.
---
## 1. Guiding Principles
1. **Workflows are kernel.** Definitions, instances, the stage graph, form validation, and
assignment queues are platform primitives — not extensions.
2. **Execution is delegated.** How a stage renders or collects data is an extension concern.
The kernel says *what* a stage needs; a surface package provides the *how*.
3. **No chat assumptions.** The kernel never references personas, channels, or AI providers.
A chat extension can participate in workflows, but the workflow engine does not depend on it.
4. **Public access is a platform capability.** Anonymous/public sessions are not workflow-specific —
they are a kernel-level feature that any surface can opt into. Workflows *consume* public
access; they don't own it. See §15.
5. **Edit existing migrations.** Per the design-decisions log: no migration chains until the
schema is in production. New tables get new files; modified tables are edited in place.
---
## 2. Disposition Index
Every item below is classified:
| Tag | Meaning |
|-----|---------|
| **🗑 TRASH** | Remove from core. Vestigial chat-switchboard concept that doesn't fit. |
| **✏️ MOD** | Exists in core today — modify in place. |
| ** ADD** | New to core. Requires new code/tables. |
| **✅ KEEP** | Already correct in core. No changes needed. |
---
## 3. Schema Changes
### 3a. `007_workflows.sql` — edit in place
#### `workflows` table
| Column | Disposition | Action |
|--------|-------------|--------|
| `entry_mode` | ✏️ MOD | Current values: `public_link`, `team_only`. Keep the column but change its meaning — it becomes a workflow-level default/flag that says "this workflow has at least one public stage." The real visibility control moves to per-stage `audience`. See note below. |
| All other columns | ✅ KEEP | Clean. No chat coupling. |
**Note on `entry_mode`:** Chat-switchboard treated this as a binary toggle — the whole
workflow was either public or not. Real workflows mix audiences: internal setup → public
form → internal review → public confirmation. The per-stage `audience` field (see below)
is the source of truth. `entry_mode` on the workflow becomes a convenience flag: if any
stage has `audience = 'public'`, the workflow is public-entry eligible. This avoids a
full schema break — existing code that checks `entry_mode` still works, it just gets set
automatically when stages are saved.
#### `workflow_stages` table
| Column | Disposition | Action |
|--------|-------------|--------|
| `persona_id` | 🗑 TRASH | Drop column. Personas are a chat-extension concept. If a stage needs an AI participant, the stage's `surface_pkg_id` extension handles that internally. The comment in the migration already acknowledges this ("personas are extensions now") but the column is still there. Remove it. |
| `stage_mode` CHECK | ✏️ MOD | Current: `form_only`, `form_chat`, `review`, `custom`. Replace with: `form`, `review`, `delegated`, `automated`. See §4 for definitions. `form_chat` is trash (chat coupling). `form_only` renames to `form`. `custom` renames to `delegated` (clearer intent). `automated` is new (no UI, Starlark-only). |
| `history_mode` | 🗑 TRASH | Drop column. This controlled how much chat history a persona saw across stages. Core has no chat history. If a delegated surface needs context management, it reads `stage_data` — that's the contract. |
| `audience` | ADD | `TEXT NOT NULL DEFAULT 'team'` with CHECK `('team', 'public', 'system')`. Controls who interacts with this stage. `team` = authenticated team members only (internal). `public` = anonymous visitors via entry token (the "public-ish" stage). `system` = no human interaction, automated only. A workflow can freely alternate: team→public→team→public. The kernel enforces this: public stages are accessible via token auth, team stages require JWT, system stages have no UI. |
| `stage_type` | ADD | `TEXT NOT NULL DEFAULT 'simple'` with CHECK `('simple', 'dynamic', 'automated')`. Drives the graph engine: `simple` = declarative rules only, `dynamic` = Starlark hook resolves next stage, `automated` = no human UI, fires hook on entry and auto-advances. |
| `starlark_hook` | ADD | `TEXT` (nullable). Package-qualified entry point for dynamic/automated stages. Format: `package_id:entry_point`. NULL for simple stages. |
| `branch_rules` | ADD | `JSONB NOT NULL DEFAULT '[]'`. Replaces the overloaded `transition_rules` for simple stage branching. Array of `{field, op, value, target_stage}`. Evaluated before `starlark_hook`, before ordinal fallback. |
| `transition_rules` | ✏️ MOD | Rename to `stage_config`. This JSONB blob was doing double duty (routing rules + on_advance hooks + auto_assign). Routing moves to `branch_rules`. What remains is stage-specific config that the kernel or surface reads: `{on_advance: {package_id, entry_point}, auto_assign: "round_robin"}`. |
| `assignment_team_id` | ✅ KEEP | Clean. Kernel concept. |
| `form_template` | ✅ KEEP | Kernel concern — structured data schema. |
| `auto_transition` | ✅ KEEP | Kernel concern — skip human interaction. |
| `surface_pkg_id` | ✅ KEEP | The delegation pointer. Required for `delegated` mode stages. |
| `sla_seconds` | ✅ KEEP | Kernel concern — time budget per stage. |
#### `workflow_versions` table
| Column | Disposition | Notes |
|--------|-------------|-------|
| All existing columns | ✅ KEEP | Immutable snapshots. No changes needed. |
### 3b. `007_workflows.sql` — add new tables (same file)
#### ADD `workflow_instances`
Replaces the channel-based instance model from chat-switchboard. This is the execution record.
```sql
CREATE TABLE IF NOT EXISTS workflow_instances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
workflow_version INTEGER NOT NULL,
current_stage INTEGER NOT NULL DEFAULT 0,
stage_data JSONB NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'completed', 'cancelled', 'stale', 'error')),
started_by UUID REFERENCES users(id) ON DELETE SET NULL,
entry_token TEXT, -- for public_link resumption
metadata JSONB NOT NULL DEFAULT '{}',
stage_entered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wf_instances_workflow
ON workflow_instances(workflow_id, status);
CREATE INDEX IF NOT EXISTS idx_wf_instances_status
ON workflow_instances(status) WHERE status = 'active';
CREATE UNIQUE INDEX IF NOT EXISTS idx_wf_instances_token
ON workflow_instances(entry_token) WHERE entry_token IS NOT NULL;
DROP TRIGGER IF EXISTS wf_instances_updated_at ON workflow_instances;
CREATE TRIGGER wf_instances_updated_at BEFORE UPDATE ON workflow_instances
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
```
**Rationale:** Chat-switchboard used `channels` as instances — that table carried message trees,
AI context windows, participant lists, and other chat concerns. Core needs a purpose-built table
that holds only workflow execution state: which version, which stage, accumulated data, lifecycle status.
#### ADD `workflow_assignments`
Dropped during the fork ("channel-dependent, rebuild as needed" — see 007 header comment).
Now rebuilt as a kernel primitive.
```sql
CREATE TABLE IF NOT EXISTS workflow_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
instance_id UUID NOT NULL REFERENCES workflow_instances(id) ON DELETE CASCADE,
stage INTEGER NOT NULL,
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
assigned_to UUID REFERENCES users(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'unassigned'
CHECK (status IN ('unassigned', 'claimed', 'completed', 'cancelled')),
review_data JSONB NOT NULL DEFAULT '{}',
claimed_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wf_assignments_instance
ON workflow_assignments(instance_id, stage);
CREATE INDEX IF NOT EXISTS idx_wf_assignments_team_status
ON workflow_assignments(team_id, status) WHERE status IN ('unassigned', 'claimed');
CREATE INDEX IF NOT EXISTS idx_wf_assignments_user
ON workflow_assignments(assigned_to) WHERE assigned_to IS NOT NULL;
```
**Claim lock:** Same optimistic pattern from chat-switchboard:
`UPDATE ... WHERE id = $1 AND status = 'unassigned'` — if `rows_affected == 0`, already claimed.
### 3c. New migration file: `012_workflow_instances.sql`
Wait — per the principle, new *tables* get new files. But we're also editing 007. Decision:
**Put the new tables in 007_workflows.sql.** The schema isn't in production. Keep all workflow
DDL in one file. When the schema *is* in production (post-MVP), new additions get numbered
migrations. This is consistent with the existing design-decisions log entry:
"No new migrations pre-MVP."
---
## 4. Stage Mode Redesign
### Current (trash/rename)
| Current Mode | Disposition | Rationale |
|-------------|-------------|-----------|
| `form_only` | ✏️ MOD → `form` | Rename. Drop the `_only` suffix — there's no `form_chat` to distinguish from anymore. |
| `form_chat` | 🗑 TRASH | Chat coupling. If you want a form + AI conversation, use `delegated` with a chat surface package. |
| `review` | ✅ KEEP | Team member reviews accumulated data. Pure kernel concept. |
| `custom` | ✏️ MOD → `delegated` | Rename for clarity. "Custom" is vague. "Delegated" makes the contract explicit: the kernel delegates stage execution to `surface_pkg_id`. |
### New modes
| Mode | Disposition | Description |
|------|-------------|-------------|
| `form` | ✏️ MOD (renamed) | Kernel renders `form_template`, validates submission, merges into `stage_data`. No extension needed. |
| `review` | ✅ KEEP | Assignment queue stage. Team member claims, reviews `stage_data`, adds `review_data`, completes. Kernel-rendered. |
| `delegated` | ✏️ MOD (renamed) | Kernel hands off to `surface_pkg_id`. The surface package receives the instance context (stage_data, form_template, metadata) and calls back to advance. This is where chat, AI, or any custom UX lives. |
| `automated` | ADD | No UI. On stage entry, kernel fires `starlark_hook`, merges returned data into `stage_data`, and auto-advances. For enrichment, API calls, validation, routing decisions. |
### Stage type vs stage mode
These are orthogonal:
- **`stage_type`** controls *how the next stage is chosen*: `simple` (declarative branch_rules), `dynamic` (Starlark hook returns target), `automated` (hook + auto-advance).
- **`stage_mode`** controls *how the stage executes*: `form`, `review`, `delegated`, `automated`.
An `automated` stage_type with `automated` stage_mode is the common case for system stages,
but you can have a `dynamic` stage_type with `form` stage_mode (the form collects data, then a
Starlark hook decides where to go next based on the submission).
---
## 5. Model Changes
### `server/models/workflow.go`
#### WorkflowStage struct — ✏️ MOD
```go
// TRASH: Remove these fields
// PersonaID *string `json:"persona_id,omitempty"`
// HistoryMode string `json:"history_mode"`
// MOD: Rename transition_rules → stage_config
// TransitionRules json.RawMessage `json:"transition_rules"`
StageConfig json.RawMessage `json:"stage_config"`
// ADD: New fields
Audience string `json:"audience"` // team | public | system
StageType string `json:"stage_type"` // simple | dynamic | automated
StarlarkHook *string `json:"starlark_hook,omitempty"` // package_id:entry_point
BranchRules json.RawMessage `json:"branch_rules"` // [{field, op, value, target_stage}]
```
#### Stage mode constants — ✏️ MOD
```go
// TRASH
// StageModeFormOnly = "form_only"
// StageModeFormChat = "form_chat"
// MOD (rename)
StageModeForm = "form" // was form_only
StageModeDelegated = "delegated" // was custom
// KEEP
StageModeReview = "review"
// ADD
StageModeAutomated = "automated"
// ADD: Stage type constants
StageTypeSimple = "simple"
StageTypeDynamic = "dynamic"
StageTypeAutomated = "automated"
// ADD: Audience constants
AudienceTeam = "team"
AudiencePublic = "public"
AudienceSystem = "system"
```
#### ADD: WorkflowInstance model
```go
type WorkflowInstance struct {
ID string `json:"id"`
WorkflowID string `json:"workflow_id"`
WorkflowVersion int `json:"workflow_version"`
CurrentStage int `json:"current_stage"`
StageData json.RawMessage `json:"stage_data"`
Status string `json:"status"` // active | completed | cancelled | stale | error
StartedBy *string `json:"started_by,omitempty"`
EntryToken *string `json:"entry_token,omitempty"`
Metadata json.RawMessage `json:"metadata"`
StageEnteredAt time.Time `json:"stage_entered_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
```
#### ADD: WorkflowAssignment model
```go
type WorkflowAssignment struct {
ID string `json:"id"`
InstanceID string `json:"instance_id"`
Stage int `json:"stage"`
TeamID string `json:"team_id"`
AssignedTo *string `json:"assigned_to,omitempty"`
Status string `json:"status"` // unassigned | claimed | completed | cancelled
ReviewData json.RawMessage `json:"review_data"`
ClaimedAt *time.Time `json:"claimed_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
```
#### TypedFormTemplate, FormField, FormValidation — ✅ KEEP
All form types are clean kernel code. No changes.
#### ValidateFormData — ✅ KEEP
Pure data validation. No chat coupling.
---
## 6. Store Interface Changes
### `server/store/workflow_iface.go` — ✏️ MOD + ADD
Current interface is definition-only (CRUD workflows + stages + versions).
Add instance and assignment operations.
```go
type WorkflowStore interface {
// ── Definition CRUD (KEEP — no changes) ──────────
Create(ctx context.Context, w *models.Workflow) error
GetByID(ctx context.Context, id string) (*models.Workflow, error)
GetBySlug(ctx context.Context, teamID *string, slug string) (*models.Workflow, error)
Update(ctx context.Context, id string, patch models.WorkflowPatch) error
Delete(ctx context.Context, id string) error
ListForTeam(ctx context.Context, teamID string) ([]models.Workflow, error)
ListGlobal(ctx context.Context) ([]models.Workflow, error)
// ── Stage CRUD (KEEP — no changes) ───────────────
CreateStage(ctx context.Context, s *models.WorkflowStage) error
ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error)
UpdateStage(ctx context.Context, s *models.WorkflowStage) error
DeleteStage(ctx context.Context, id string) error
ReorderStages(ctx context.Context, workflowID string, orderedIDs []string) error
// ── Versioning (KEEP — no changes) ───────────────
Publish(ctx context.Context, v *models.WorkflowVersion) error
GetVersion(ctx context.Context, workflowID string, versionNumber int) (*models.WorkflowVersion, error)
GetLatestVersion(ctx context.Context, workflowID string) (*models.WorkflowVersion, error)
// ── Instances (ADD) ──────────────────────────────
CreateInstance(ctx context.Context, inst *models.WorkflowInstance) error
GetInstance(ctx context.Context, id string) (*models.WorkflowInstance, error)
GetInstanceByToken(ctx context.Context, token string) (*models.WorkflowInstance, error)
UpdateInstance(ctx context.Context, id string, patch models.InstancePatch) error
ListInstances(ctx context.Context, workflowID string, status string) ([]models.WorkflowInstance, error)
AdvanceStage(ctx context.Context, id string, nextStage int, mergeData json.RawMessage) error
CompleteInstance(ctx context.Context, id string) error
CancelInstance(ctx context.Context, id string) error
MarkStale(ctx context.Context, olderThan time.Duration) (int, error)
// ── Assignments (ADD) ────────────────────────────
CreateAssignment(ctx context.Context, a *models.WorkflowAssignment) error
ClaimAssignment(ctx context.Context, id string, userID string) error // optimistic lock
UnclaimAssignment(ctx context.Context, id string) error
CompleteAssignment(ctx context.Context, id string, reviewData json.RawMessage) error
CancelAssignment(ctx context.Context, id string) error
ListAssignmentsByTeam(ctx context.Context, teamID string, status string) ([]models.WorkflowAssignment, error)
ListAssignmentsByUser(ctx context.Context, userID string) ([]models.WorkflowAssignment, error)
ListAssignmentsByInstance(ctx context.Context, instanceID string) ([]models.WorkflowAssignment, error)
}
```
---
## 7. Handler Changes
### `server/handlers/workflows.go` — ✏️ MOD
**Stage CRUD updates** — reflect new field names in CreateStage/UpdateStage:
- Remove `persona_id` and `history_mode` from bind/validation.
- Add `stage_type`, `starlark_hook`, `branch_rules` to bind/validation.
- Rename `transition_rules``stage_config` in bind/validation.
- Update `stage_mode` CHECK to new set: `form`, `review`, `delegated`, `automated`.
- Validate: `delegated` requires `surface_pkg_id`. `dynamic`/`automated` stage_type requires `starlark_hook`.
### `server/handlers/workflow_hooks.go` — ✏️ MOD
**Rename:** `channelID` parameter → `instanceID` in `FireOnAdvanceHook` signature.
The hook context dict changes from `{channel_id, ...}` to `{instance_id, ...}`.
The `jsonToStarlark` helper referenced on line 81 lives in
`server/handlers/starlark_helpers.go`. Consider consolidating with `goToStarlark` in
`server/triggers/event.go` — two independent Go→Starlark converters is tech debt.
### `server/handlers/workflow_team.go` — ✅ KEEP
Team-scoped wrappers. Clean delegation pattern. No changes.
### `server/handlers/workflow_packages.go` — ✏️ MOD
Update `workflowPkgStage` struct:
- Remove `PersonaID`, `HistoryMode` fields.
- Add `StageType`, `StarlarkHook`, `BranchRules` fields.
- Rename `TransitionRules``StageConfig`.
Update `ExportWorkflowPackage` and `InstallWorkflowFromManifest` accordingly.
### ADD `server/handlers/workflow_instances.go`
New handler file for instance lifecycle:
```
POST /api/v1/workflows/:id/start → Start (create instance from latest published version)
GET /api/v1/workflows/instances/:iid → GetInstance
POST /api/v1/workflows/instances/:iid/advance → Advance (submit stage data, resolve next)
POST /api/v1/workflows/instances/:iid/cancel → Cancel
GET /api/v1/workflows/:id/instances → ListInstances (by workflow, optionally by status)
```
Public entry (for `public_link` workflows):
```
POST /api/v1/workflows/entry/:slug → StartPublic (no auth, generates entry_token)
GET /api/v1/workflows/entry/:token → ResumePublic (retrieve instance by token)
POST /api/v1/workflows/entry/:token/advance → AdvancePublic (submit data via token)
```
### ADD `server/handlers/workflow_assignments.go`
New handler file for the assignment queue:
```
GET /api/v1/teams/:teamId/assignments → ListTeamAssignments (filterable by status)
POST /api/v1/teams/:teamId/assignments/:id/claim → Claim
POST /api/v1/teams/:teamId/assignments/:id/unclaim → Unclaim
POST /api/v1/teams/:teamId/assignments/:id/complete → Complete (with review_data)
POST /api/v1/teams/:teamId/assignments/:id/cancel → Cancel
GET /api/v1/assignments/mine → ListMyAssignments (across teams)
```
---
## 8. Routing Engine Changes
### `server/workflow/routing.go` — ✏️ MOD
The existing `ResolveNextStage` evaluates `transition_rules.conditions[]`. This needs to
become a two-phase resolution that respects the new `stage_type`:
```
Phase 1: Evaluate branch_rules (declarative, for simple + dynamic types)
Phase 2: If no match AND stage_type == dynamic → fire starlark_hook
Phase 3: Fallback → currentStage + 1
```
For `automated` stage_type, the engine fires `starlark_hook` on entry (not for routing — for
data enrichment), then routes via branch_rules or ordinal fallback. The routing call happens
*after* the hook returns.
**Concrete changes:**
- `ResolveNextStage` signature: add `stageType string, starlarkHook *string, runner *sandbox.Runner` parameters.
- Extract branch_rules evaluation from the current `TransitionRulesWithConditions` (which reads from `transition_rules` JSON) into a dedicated function that reads from the new `branch_rules` field.
- `TransitionRulesWithConditions` struct → rename to `StageConfig`, remove `Conditions` field (moved to branch_rules), keep `AutoAssign` and `OnAdvance`.
### `server/workflow/` — ADD `engine.go`
New file: the stage execution engine. Orchestrates the advance lifecycle:
```
1. Load instance + version snapshot
2. Validate current stage allows advancement (status checks)
3. If current stage has form_template → validate submitted data
4. Merge submitted data into stage_data
5. Fire on_advance hook (if configured in stage_config)
6. Resolve next stage (branch_rules → starlark_hook → ordinal)
7. If next stage is past last stage → complete instance
8. If next stage is automated → fire its hook, recurse to step 6
9. If next stage has assignment_team_id → create WorkflowAssignment
10. Update instance (current_stage, stage_data, stage_entered_at)
11. Emit bus events (workflow.advanced, workflow.assigned, workflow.completed)
```
### `server/workflow/` — ADD `automated.go`
Handles `automated` stage execution: fire the Starlark hook, merge returned data, and
auto-advance. Includes a cycle guard (max 10 consecutive automated stages) to prevent
infinite loops from misconfigured workflows.
---
## 9. Event Bus Updates
### `server/events/types.go` — ✏️ MOD
Current workflow events are fine but incomplete. Add:
| Event | Direction | Disposition | Trigger |
|-------|-----------|-------------|---------|
| `workflow.assigned` | DirToClient | ✅ KEEP | Assignment created |
| `workflow.claimed` | DirToClient | ✅ KEEP | Assignment claimed |
| `workflow.advanced` | DirToClient | ✅ KEEP | Stage transition |
| `workflow.completed` | DirToClient | ✅ KEEP | Instance completed |
| `workflow.cancelled` | DirToClient | ADD | Instance cancelled |
| `workflow.started` | DirToClient | ADD | Instance created |
| `workflow.sla.warning` | DirToClient | ADD | SLA at 80% threshold |
| `workflow.sla.breached` | DirToClient | ADD | SLA exceeded |
| `workflow.error` | DirLocal | ADD | Hook execution failure |
---
## 10. Starlark Module Updates
### `server/sandbox/workflow_module.go` — ✏️ MOD
The `BuildWorkflowModule` (in `workflow_module.go`, not `modules.go`) currently exists but
targets the old channel-based model. It currently only exposes `get_definition`.
Rebuild to expose instance-oriented operations:
```python
# Available to extensions with workflow.read or workflow.write permission
workflow.get_instance(instance_id) # → instance dict
workflow.get_stage_data(instance_id) # → stage_data dict
workflow.set_stage_data(instance_id, data) # → merge into stage_data
workflow.advance(instance_id) # → trigger advance (write perm)
workflow.get_assignments(instance_id) # → assignment list
```
The old `workflow_advance()` function that chat-switchboard's personas called is gone.
Extensions call `workflow.advance()` which delegates to the same engine as the HTTP handler.
---
## 11. Salvage Map from chat-switchboard v0.39.x
What was planned there, and what happens to each idea in core:
### v0.39.0 — Stage Graph Engine + Form Builder
| Component | Disposition | Core equivalent |
|-----------|-------------|-----------------|
| `stage_type` enum | ADD | Adopted directly: `simple`, `dynamic`, `automated` |
| `BranchRule` struct | ADD | Adopted as `branch_rules` JSONB on `workflow_stages` |
| Starlark hook integration | ADD | `starlark_hook` column + engine integration |
| Graph engine (`server/engine/graph.go`) | ADD | `server/workflow/engine.go` in core |
| Automated stage runner | ADD | `server/workflow/automated.go` in core |
| Visual form builder (5 Preact components) | 🗑 TRASH *for now* | The form builder was tightly coupled to chat-switchboard's stage editor surface. Core doesn't ship a workflow editor surface (that's an extension). The form *schema* is kernel; the form *builder UI* is a surface package concern. Revisit when a workflow-admin surface is built. |
| Stage type badges in UI | 🗑 TRASH | Same — UI is extension territory. |
### v0.39.1 — Stage Reorder + Bulk Ops
| Component | Disposition | Core equivalent |
|-----------|-------------|-----------------|
| Drag-and-drop reorder | 🗑 TRASH | UI concern. The kernel `ReorderStages` endpoint already exists. |
| Bulk assignment ops | ADD (later) | Useful but not blocking. Defer to v0.2.7+. The individual claim/unclaim/cancel endpoints come first. |
### v0.39.2 — SLA Alerting
| Component | Disposition | Core equivalent |
|-----------|-------------|-----------------|
| Periodic SLA scanner | ADD | Kernel scheduled goroutine (like the existing staleness sweep). Query active instances with `sla_seconds` configured, compute breach from `stage_entered_at`. |
| `sla_notified_at` on instances | ADD | Add to `workflow_instances` schema. Prevents duplicate notifications. |
| Notifications on breach | ADD | Use existing `notifications` table + bus event (`workflow.sla.breached`). |
| Webhook on breach | ADD | Fire workflow's `webhook_url` if configured. |
| Pulsing badge animation | 🗑 TRASH | UI concern for a surface package. |
### v0.39.3 — Visitor Portal
| Component | Disposition | Core equivalent |
|-----------|-------------|-----------------|
| Public entry endpoint | ADD | `/api/v1/workflows/entry/:slug` — kernel handles anonymous session creation + token issuance. |
| Session resume via token | ADD | `entry_token` on `workflow_instances` + `GetInstanceByToken` store method. |
| Branded entry page | 🗑 TRASH | Surface package. The kernel stores `branding` JSONB — a portal surface reads it. |
| Progress indicator | 🗑 TRASH | Surface package reads stage list, computes "step N of M" locally. |
| Email notifications | 🗑 TRASH *for now* | Requires SMTP infrastructure. Defer to post-MVP or make it a connection-based extension (use ext_connections for SMTP config, fire via Starlark hook). |
### v0.39.4 — Templates + Clone
| Component | Disposition | Core equivalent |
|-----------|-------------|-----------------|
| Clone endpoint | ADD | `POST /api/v1/workflows/:id/clone` — deep copy workflow + stages. Straightforward. |
| Built-in templates table | 🗑 TRASH | Core doesn't ship opinionated templates. Templates are workflow packages (`.pkg` archives). A "template gallery" is a surface. |
| Template picker UI | 🗑 TRASH | Surface concern. |
### v0.39.5 — Analytics
| Component | Disposition | Core equivalent |
|-----------|-------------|-----------------|
| Analytics query endpoint | ADD (later) | `GET /api/v1/workflows/:id/analytics?period=7d` — kernel exposes raw metrics (throughput, cycle time, stage dwell, SLA compliance) derived from instance + assignment timestamps. Defer to v0.2.8+ or v0.3.x. |
| Analytics dashboard UI | 🗑 TRASH | Surface package. |
---
## 12. Implementation Order
All items within v0.2.6. Ordered by dependency:
### Phase A: Schema + Models
1. ✏️ Edit `007_workflows.sql`: drop `persona_id`, `history_mode`; add `audience`, `stage_type`, `starlark_hook`, `branch_rules`; rename `transition_rules``stage_config`; update `stage_mode` CHECK.
2. ✏️ Edit `007_workflows.sql` (same file): add `workflow_instances` and `workflow_assignments` tables.
3. ✏️ Edit `007_workflows.sql` (SQLite dialect): mirror changes.
4. ✏️ Update `server/models/workflow.go`: remove trashed fields, add new structs, update constants.
### Phase B: Store Layer
5. ✏️ Update `server/store/workflow_iface.go`: add instance + assignment methods.
6. Implement Postgres store: `server/store/postgres/workflow_instances.go`, `workflow_assignments.go`.
7. Implement SQLite store: `server/store/sqlite/workflow_instances.go`, `workflow_assignments.go`.
### Phase C: Engine
8. ✏️ Update `server/workflow/routing.go`: branch_rules evaluation, starlark_hook integration.
9. Add `server/workflow/engine.go`: advance lifecycle orchestration.
10. Add `server/workflow/automated.go`: automated stage execution + cycle guard.
11. ✏️ Update `server/handlers/workflow_hooks.go`: rename channel → instance references.
### Phase D: Handlers + Routes
12. ✏️ Update `server/handlers/workflows.go`: stage CRUD field changes.
13. ✏️ Update `server/handlers/workflow_packages.go`: package struct field changes.
14. Add `server/handlers/workflow_instances.go`: instance lifecycle endpoints.
15. Add `server/handlers/workflow_assignments.go`: assignment queue endpoints.
16. ✏️ Wire new routes in `server/main.go`.
### Phase E: Events + Starlark
17. ✏️ Update `server/events/types.go`: add new workflow events.
18. ✏️ Update `server/sandbox/modules.go`: rebuild workflow Starlark module.
### Phase F: Background Jobs
19. Add SLA scanner goroutine (check active instances, fire notifications + events on breach).
20. Add staleness sweep for workflow instances (reuse pattern from chat-switchboard).
### Phase G: Verification
21. Integration tests: instance lifecycle, stage advancement, branching, automated stages, assignments, SLA breach.
22. `go build ./...` clean.
23. Update ICD (OpenAPI spec) with new endpoints.
24. Update ROADMAP.md + CHANGELOG.md.
---
## 13. What We're NOT Doing (Explicit Deferrals)
| Item | Why | Revisit |
|------|-----|---------|
| Visual form builder UI | Surface concern, no workflow-admin surface yet | When a workflow-admin surface ships |
| Drag-and-drop stage reorder UI | Surface concern | Same |
| Email notifications | Needs SMTP infrastructure or connection-based ext | Post-MVP |
| Built-in workflow templates | Core doesn't ship opinionated content | Package gallery |
| Analytics dashboard | Nice-to-have, kernel endpoint can come first | v0.3.x |
| Bulk assignment operations | Individual ops first | v0.2.7+ |
| Workflow chaining (`on_complete`) | Already in schema, needs engine wiring | v0.2.7 |
| Visitor portal surface | Surface package, not kernel | Extension track |
---
## 14. Migration Policy Reminder
From the design-decisions log:
> **No new migrations pre-MVP.** Edit existing migration SQL files in place.
> No migration chains until schema is in production.
For this redesign:
- **`007_workflows.sql`**: Edit in place for column changes AND add new tables to the same file.
- **No `012_*.sql`**: Everything goes in 007.
- **Both dialects**: Postgres and SQLite must be kept in sync.
The only time a new numbered migration file is created is when a truly new,
unrelated subsystem is added (like 011_triggers.sql was for the trigger system).
Workflow instances and assignments are part of the workflow subsystem → they go in 007.
---
## 15. Public Surfaces — Kernel Capability, Not Workflow Feature
### The problem with workflow-owned public access
Chat-switchboard treated public access as a binary workflow toggle: `entry_mode: public_link`
made the whole workflow public. But real workflows alternate audiences — internal setup →
public-facing customer form → internal review → public confirmation page. And public access
isn't even a workflow-only need: feedback forms, status pages, and surveys all need anonymous
sessions without being workflows.
### Per-stage audience (the v0.2.6 solution)
The `audience` field on `workflow_stages` is the source of truth:
| Audience | Who interacts | Auth required | Example |
|----------|--------------|---------------|---------|
| `team` | Authenticated team members | JWT | Internal triage, setup, review |
| `public` | Anonymous visitors | Entry token | Customer intake form, confirmation page |
| `system` | Nobody (automated) | N/A | API enrichment, routing decision |
A single workflow freely alternates:
```
Stage 0: "Setup" audience=team (agent configures parameters)
Stage 1: "Customer Form" audience=public (customer fills out intake)
Stage 2: "Internal Review" audience=team (team reviews submission)
Stage 3: "Confirmation" audience=public (customer sees result)
```
The engine enforces boundaries:
- When the instance enters a `public` stage, the visitor's token grants access. Team members
can also see it (they have elevated access).
- When the instance enters a `team` stage, the visitor's view freezes. They see "being
processed" or whatever the portal surface shows. The token is still valid for resumption
when the next `public` stage arrives.
- When the instance enters a `system` stage, nobody interacts — the automated hook fires
and the engine advances.
### The `entry_mode` column
Stays on the `workflows` table but becomes a derived convenience flag. If any stage has
`audience = 'public'`, the workflow is public-entry eligible. The kernel can auto-compute
this when stages are saved, or treat it as an explicit admin toggle that gates whether
public stages are actually reachable. Either way, it's no longer the primary access control
mechanism — `audience` on each stage is.
### Platform-level public access (v0.2.7+ concern)
The broader vision: public access as a **package-level manifest capability** so any surface
(not just workflows) can serve anonymous visitors. A package declares `"public": true` in
its manifest, and the kernel mounts a public route namespace (`/p/:package_slug/*`) with
anonymous session middleware, token issuance, and rate limiting. Workflows consume this
capability — they don't own it.
For v0.2.6, the workflow-specific public entry routes are sufficient:
```
POST /api/v1/workflows/entry/:slug → StartPublic
GET /api/v1/workflows/entry/:token → ResumePublic
POST /api/v1/workflows/entry/:token/advance → AdvancePublic
```
The anonymous session management (entry_token on `workflow_instances`, IP rate limiting)
lives in the workflow handler for now. Generalization to a platform-level `public_sessions`
table happens when non-workflow surfaces need it.
### Visitor view contract
When a public stage is active, the kernel API returns only what the visitor should see:
- The current stage's `form_template` (if mode is `form`)
- The stage name and ordinal (for progress indicators)
- Accumulated `stage_data` keys marked as `visitor_visible` (future: field-level ACL)
- The `branding` JSONB from the workflow
Internal stages, team assignments, review data, and other instances' data are never
exposed through the token-authenticated endpoints. The surface package renders whatever
the kernel returns — it doesn't need to filter.
---
## 16. Branch Rules — Routing, Skipping, and Convergence
### How branch_rules work
Each stage has a `branch_rules` JSONB array. On stage completion, the engine evaluates rules
top-to-bottom against accumulated `stage_data`. First match wins. No match → ordinal + 1.
```json
[
{"field": "priority", "op": "eq", "value": "urgent", "target_stage": "Emergency Review"},
{"field": "amount", "op": "gt", "value": 10000, "target_stage": "Manager Approval"},
{"field": "type", "op": "in", "value": ["a","b"], "target_stage": "Fast Track"}
]
```
`target_stage` accepts either a **stage name** (case-insensitive) or a **numeric ordinal**.
The engine tries name resolution first, falls back to numeric. This is already implemented
in `routing.go``resolveTarget()`.
### Skipping stages
Branch rules can target any stage, not just the next one. If a workflow has stages
0→1→2→3→4 and stage 1's rules say `target_stage: "Stage 4"`, stages 2 and 3 are skipped
for that instance. The engine doesn't care about gaps — it sets `current_stage` to whatever
the rule resolved to.
Linear workflows (no branch_rules on any stage) advance 0→1→2→3→4 by the ordinal + 1
fallback. Adding a single branch rule to any stage introduces conditional skipping without
changing the linear stages.
### Convergence (diamond patterns)
Two different branches can target the same downstream stage:
```
Stage 0: Intake Form
branch_rules: [
{field: "region", op: "eq", value: "US", target_stage: "US Processing"},
{field: "region", op: "eq", value: "EU", target_stage: "EU Processing"}
]
Stage 1: US Processing (ordinal 1)
branch_rules: [{field: "*", op: "exists", target_stage: "Final Review"}]
(always converge after processing)
Stage 2: EU Processing (ordinal 2)
branch_rules: [{field: "*", op: "exists", target_stage: "Final Review"}]
(always converge after processing)
Stage 3: Final Review (ordinal 3)
(both branches land here)
```
No special syntax needed. The routing engine doesn't track "which branch am I on" — it just
resolves the target and moves the instance there. The accumulated `stage_data` carries
everything downstream stages need to know about what happened upstream.
### Backward jumps
Branch rules can also target earlier stages (lower ordinals) for retry/correction loops.
The cycle guard (max 10 consecutive automated stages) in `automated.go` prevents infinite
loops for automated stages. For human-facing stages, backward jumps are intentional — the
human breaks the cycle by submitting different data.
### Operators
Full set, inherited from the existing `routing.go` implementation:
| Op | Description | Example |
|----|-------------|---------|
| `eq` | Loose equality (string comparison) | `"status" eq "approved"` |
| `neq` | Not equal | `"status" neq "rejected"` |
| `gt` | Greater than (numeric) | `"amount" gt 10000` |
| `lt` | Less than (numeric) | `"amount" lt 100` |
| `gte` | Greater or equal | `"score" gte 80` |
| `lte` | Less or equal | `"score" lte 20` |
| `in` | Value in array | `"category" in ["a","b","c"]` |
| `contains` | Substring match | `"notes" contains "urgent"` |
| `exists` | Field present in stage_data | `"approval_date" exists` |
| `not_exists` | Field absent | `"rejection_reason" not_exists` |

111
docs/DESIGN-WORKFLOWS.md Normal file
View File

@@ -0,0 +1,111 @@
# Workflow Engine — Design
The workflow engine provides multi-stage, form-driven processes with team
validation, SLA enforcement, and public entry. Workflows are kernel primitives;
workflow **surfaces** (admin UI, public landing pages) are extension packages.
---
## Core Concepts
### Workflow Definition
A workflow is a named process template owned by a team. Each workflow defines an
ordered list of **stages** that instances progress through.
| Field | Description |
|-------|-------------|
| `name` | Human-readable workflow name |
| `slug` | URL-safe identifier (unique per scope) |
| `scope` | Owning team or org |
| `stages` | Ordered list of stage definitions |
| `entry_mode` | `internal` (authenticated users) or `public_link` (landing page) |
| `sla_hours` | Optional SLA deadline for instance completion |
### Stage Types
Each stage has a `mode` that determines how participants interact:
| Mode | Behavior |
|------|----------|
| `form_only` | Structured form submission. Stage advances when form is complete. |
| `form_chat` | Form plus threaded discussion. Useful for review with comments. |
| `review` | Approval/rejection gate. Designated reviewers must sign off. |
| `custom` | Delegates entirely to a surface package. Enables extension-driven UIs. |
Stages declare an `assignee_mode` (individual, team, role-based) and optional
form schemas (JSON Schema validated at submission time).
### Workflow Instances
An instance is a running execution of a workflow definition. It tracks:
- Current stage index and overall status (`active`, `completed`, `cancelled`, `stale`)
- Per-stage completion records with timestamps and actor IDs
- Form data submitted at each stage
- Assignment and claim history
Instances use optimistic locking (`claimed_by` + `claimed_at`) to prevent
concurrent stage advancement. A claim expires after a configurable timeout.
---
## Multi-Party Validation (Signoff Table)
The `workflow_signoffs` table enables multi-party approval gates:
| Column | Purpose |
|--------|---------|
| `instance_id` | Which instance |
| `stage_index` | Which stage requires signoff |
| `user_id` | Who signed |
| `decision` | `approved` or `rejected` |
| `comment` | Optional rationale |
| `signed_at` | Timestamp |
The engine checks signoff requirements before advancing past a `review` stage.
Requirements are configurable: unanimous, majority, or N-of-M.
---
## SLA Scanner + Staleness Sweep
Two periodic background jobs maintain workflow health:
**SLA Scanner** — Runs on a configurable interval. Identifies instances that have
exceeded their workflow's `sla_hours` deadline. Fires a `workflow.sla.breached`
event (consumed by notification extensions or triggers).
**Staleness Sweep** — Identifies instances that have been idle (no stage
advancement) beyond a configurable threshold. Marks them as `stale` and fires
`workflow.instance.stale`. Stale instances can be resumed or cancelled.
Both jobs are distributed-safe: all cluster nodes run them, but operations are
idempotent (UPDATE with WHERE guards).
---
## Public Entry
Workflows with `entry_mode: public_link` are accessible at:
```
/w/:scope/:slug
```
The landing page renders the first stage's form without requiring authentication.
On submission, the kernel creates an instance and stores the form data. Subsequent
stages may require authentication depending on their assignee configuration.
Public entry enables use cases like intake forms, support requests, and
application submissions where the initiator is external.
---
## Extension Points
- **Surface packages** provide workflow admin UIs and participant views
- **Trigger extensions** can react to workflow events (`instance.created`,
`stage.advanced`, `sla.breached`, `instance.completed`)
- **Custom stage mode** delegates rendering and advancement logic entirely to
an extension, enabling arbitrary UIs within a workflow stage

View File

@@ -0,0 +1,119 @@
# Build Your First Browser Extension
This tutorial walks through building a browser extension that renders custom
code blocks, modeled on the CSV Table Viewer that ships with Switchboard.
**Prerequisites:** A running Switchboard instance, a text editor, and `zip`.
## Step 1: Create the Directory
```sh
mkdir -p my-extension/js
```
## Step 2: Write the Manifest
Create `my-extension/manifest.json`:
```json
{
"id": "my-extension",
"title": "My Extension",
"version": "0.1.0",
"type": "extension",
"tier": "browser",
"author": "you",
"description": "Renders demo code blocks as styled HTML",
"permissions": [],
"settings": {}
}
```
- **id** -- unique identifier, used as the install key
- **type** -- `extension` for browser-only packages; `full` or `surface` for
packages with backend routes
- **tier** -- `browser` for client-side JS; `starlark` for server-side scripting
## Step 3: Write the Browser Script
Create `my-extension/js/script.js`. Browser extensions use the IIFE pattern
and register with the SDK through `sw.renderers`:
```js
(function () {
'use strict';
function register() {
if (!window.sw?.renderers) return;
sw.renderers.register('demo-block', {
type: 'block',
priority: 10,
match(lang) {
return (lang || '').toLowerCase() === 'demo';
},
render(lang, code, container) {
container.innerHTML =
'<div style="padding:12px;background:var(--bg-2);' +
'border:1px solid var(--border);border-radius:8px">' +
'<strong>Demo:</strong> ' + code +
'</div>';
}
});
}
if (window.sw?._sdk) {
register();
} else {
document.addEventListener('sw:ready', register, { once: true });
}
})();
```
The IIFE wrapper keeps variables out of global scope. `sw.renderers.register`
takes a name and an options object: `type: 'block'` targets fenced code blocks,
`match` checks the language tag, and `render` receives the language, raw code,
and a container element. The `sw:ready` event fires once the SDK initializes;
if already loaded, register immediately. Use CSS variables like `var(--bg-2)`
and `var(--border)` to follow the active theme.
## Step 4: Package It
```sh
cd my-extension
zip -r ../my-extension.pkg manifest.json js/
```
The `.pkg` format is a ZIP with `manifest.json` at the root. Optional
directories: `js/`, `css/`, `assets/`. If working inside `packages/`, use
the build script instead: `bash build.sh my-extension`.
## Step 5: Install It
```sh
curl -X POST http://localhost:3000/api/v1/admin/packages/install \
-H "Authorization: Bearer <token>" \
-F "file=@my-extension.pkg"
```
You can also install through the Admin UI under the Packages section.
## Step 6: Enable and Test
1. Open the admin panel, navigate to Packages, confirm "My Extension" is enabled.
2. Go to any markdown surface (Chat, Notes, etc.).
3. Enter a fenced code block with the `demo` language tag.
You should see styled output instead of a plain code block.
## Going Further
- **Add CSS** -- create a `css/` directory; stylesheets are injected automatically.
- **Post-renderers** -- register with `type: 'post'` to run after block renderers
finish (the Mermaid extension uses this for async SVG rendering).
- **Settings** -- declare a `settings` object in the manifest for admin-configurable values.
- **Server-side logic** -- set `tier: "starlark"` and add `script.star` for
backend API routes and database tables.
See `PACKAGE-FORMAT.md` for the full manifest spec and `EXTENSION-GUIDE.md`
for advanced patterns.

View File

@@ -258,7 +258,8 @@
<${Button} size="sm" onClick=${saveEdit}>Save<//>
</div>
</div>
` : html`
` : msg.content_type === 'markdown' && sw?.markdown?.ready ? html`
<div class="chat-msg__content" dangerouslySetInnerHTML=${{ __html: sw.markdown.renderSync(msg.content, { sanitize: true }) }} />` : html`
<div class="chat-msg__content">${msg.content}</div>`}
<div class="chat-msg__meta">
<span class="chat-msg__time">${timeAgo(msg.created_at)}</span>

View File

@@ -3,136 +3,20 @@
// ==========================================
// Renders ```csv and ```tsv code blocks as sortable HTML tables.
// No external dependencies.
//
// Registers with sw.renderers via the sw:ready event.
// ==========================================
Extensions.register({
id: 'csv-table',
(function () {
'use strict';
async init(ctx) {
const self = this;
// ── Inject styles ──
this._injectStyles();
ctx.renderers.register('csv-block', {
type: 'block',
priority: 10,
match(lang) {
const l = (lang || '').toLowerCase();
return l === 'csv' || l === 'tsv';
},
render(lang, code, container) {
const delimiter = lang.toLowerCase() === 'tsv' ? '\t' : ',';
const rows = self._parseCSV(code.trim(), delimiter);
if (rows.length === 0) {
container.innerHTML = '<div class="csv-empty">No data</div>';
return;
function _escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
const headers = rows[0];
const data = rows.slice(1);
const tableId = 'csv-' + Math.random().toString(36).slice(2, 9);
container.innerHTML = `
<div class="csv-table-block ext-rendered" data-csv-id="${tableId}">
<div class="csv-toolbar">
<span class="csv-info">${data.length} row${data.length !== 1 ? 's' : ''} × ${headers.length} col${headers.length !== 1 ? 's' : ''}</span>
<button class="csv-copy-btn" title="Copy as CSV">📋 Copy</button>
</div>
<div class="csv-table-scroll">
<table class="csv-table">
<thead>
<tr>${headers.map((h, i) => `<th data-col="${i}" title="Click to sort">${self._escapeHtml(h)}<span class="csv-sort-icon"></span></th>`).join('')}</tr>
</thead>
<tbody>
${data.map(row => `<tr>${row.map(cell => `<td>${self._escapeHtml(cell)}</td>`).join('')}</tr>`).join('')}
</tbody>
</table>
</div>
<details class="csv-source">
<summary>📄 View raw</summary>
<pre><code>${self._escapeHtml(code.trim())}</code></pre>
</details>
</div>
`;
// Attach sort handlers + copy
self._attachHandlers(container, tableId, code.trim());
}
});
},
_attachHandlers(container, tableId, rawCSV) {
const block = container.querySelector(`[data-csv-id="${tableId}"]`);
if (!block) return;
// ── Column sorting ──
const ths = block.querySelectorAll('th[data-col]');
let sortCol = -1;
let sortAsc = true;
ths.forEach(th => {
th.style.cursor = 'pointer';
th.addEventListener('click', () => {
const col = parseInt(th.dataset.col, 10);
if (sortCol === col) {
sortAsc = !sortAsc;
} else {
sortCol = col;
sortAsc = true;
}
const tbody = block.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
const cellA = (a.children[col]?.textContent || '').trim();
const cellB = (b.children[col]?.textContent || '').trim();
const numA = parseFloat(cellA);
const numB = parseFloat(cellB);
// Numeric sort if both are numbers
if (!isNaN(numA) && !isNaN(numB)) {
return sortAsc ? numA - numB : numB - numA;
}
// String sort
const cmp = cellA.localeCompare(cellB, undefined, { numeric: true, sensitivity: 'base' });
return sortAsc ? cmp : -cmp;
});
rows.forEach(row => tbody.appendChild(row));
// Update sort icons
ths.forEach(h => {
const icon = h.querySelector('.csv-sort-icon');
if (icon) {
const hCol = parseInt(h.dataset.col, 10);
icon.textContent = hCol === sortCol ? (sortAsc ? ' ▲' : ' ▼') : '';
}
});
});
});
// ── Copy button ──
const copyBtn = block.querySelector('.csv-copy-btn');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(rawCSV).then(() => {
copyBtn.textContent = '✓ Copied';
setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500);
}).catch(() => {
copyBtn.textContent = '✗ Failed';
setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500);
});
});
}
},
/**
* Simple CSV parser that handles quoted fields.
*/
_parseCSV(text, delimiter) {
function _parseCSV(text, delimiter) {
const rows = [];
let row = [];
let field = '';
@@ -141,64 +25,98 @@ Extensions.register({
while (i < text.length) {
const ch = text[i];
if (inQuotes) {
if (ch === '"') {
// Peek ahead: escaped quote or end of quoted field
if (i + 1 < text.length && text[i + 1] === '"') {
field += '"';
i += 2;
field += '"'; i += 2;
} else {
inQuotes = false;
i++;
inQuotes = false; i++;
}
} else {
field += ch;
i++;
field += ch; i++;
}
} else {
if (ch === '"' && field === '') {
inQuotes = true;
i++;
inQuotes = true; i++;
} else if (ch === delimiter) {
row.push(field.trim());
field = '';
i++;
row.push(field.trim()); field = ''; i++;
} else if (ch === '\n' || (ch === '\r' && text[i + 1] === '\n')) {
row.push(field.trim());
if (row.some(cell => cell !== '')) rows.push(row);
row = [];
field = '';
row = []; field = '';
i += (ch === '\r') ? 2 : 1;
} else {
field += ch;
i++;
field += ch; i++;
}
}
}
// Last field/row
row.push(field.trim());
if (row.some(cell => cell !== '')) rows.push(row);
// Normalize: pad short rows to header length
if (rows.length > 0) {
const maxCols = Math.max(...rows.map(r => r.length));
rows.forEach(r => {
while (r.length < maxCols) r.push('');
});
rows.forEach(r => { while (r.length < maxCols) r.push(''); });
}
return rows;
}
return rows;
},
function _attachHandlers(container, tableId, rawCSV) {
const block = container.querySelector(`[data-csv-id="${tableId}"]`);
if (!block) return;
_escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
const ths = block.querySelectorAll('th[data-col]');
let sortCol = -1;
let sortAsc = true;
_injectStyles() {
ths.forEach(th => {
th.style.cursor = 'pointer';
th.addEventListener('click', () => {
const col = parseInt(th.dataset.col, 10);
if (sortCol === col) { sortAsc = !sortAsc; }
else { sortCol = col; sortAsc = true; }
const tbody = block.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
const cellA = (a.children[col]?.textContent || '').trim();
const cellB = (b.children[col]?.textContent || '').trim();
const numA = parseFloat(cellA);
const numB = parseFloat(cellB);
if (!isNaN(numA) && !isNaN(numB))
return sortAsc ? numA - numB : numB - numA;
const cmp = cellA.localeCompare(cellB, undefined, { numeric: true, sensitivity: 'base' });
return sortAsc ? cmp : -cmp;
});
rows.forEach(row => tbody.appendChild(row));
ths.forEach(h => {
const icon = h.querySelector('.csv-sort-icon');
if (icon) {
const hCol = parseInt(h.dataset.col, 10);
icon.textContent = hCol === sortCol ? (sortAsc ? ' \u25b2' : ' \u25bc') : '';
}
});
});
});
const copyBtn = block.querySelector('.csv-copy-btn');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(rawCSV).then(() => {
copyBtn.textContent = '\u2713 Copied';
setTimeout(() => { copyBtn.textContent = '\ud83d\udccb Copy'; }, 1500);
}).catch(() => {
copyBtn.textContent = '\u2717 Failed';
setTimeout(() => { copyBtn.textContent = '\ud83d\udccb Copy'; }, 1500);
});
});
}
}
// ── Styles ──────────────────────────────
function _injectStyles() {
if (document.getElementById('ext-style-csv-table')) return;
const style = document.createElement('style');
style.id = 'ext-style-csv-table';
@@ -246,9 +164,66 @@ Extensions.register({
}
.csv-empty { padding: 16px; text-align: center; color: var(--text-3); }`;
document.head.appendChild(style);
},
destroy() {
document.getElementById('ext-style-csv-table')?.remove();
}
});
// ── Registration ────────────────────────
function register() {
if (!window.sw?.renderers) return;
_injectStyles();
sw.renderers.register('csv-block', {
type: 'block',
priority: 10,
match(lang) {
const l = (lang || '').toLowerCase();
return l === 'csv' || l === 'tsv';
},
render(lang, code, container) {
const delimiter = lang.toLowerCase() === 'tsv' ? '\t' : ',';
const rows = _parseCSV(code.trim(), delimiter);
if (rows.length === 0) {
container.innerHTML = '<div class="csv-empty">No data</div>';
return;
}
const headers = rows[0];
const data = rows.slice(1);
const tableId = 'csv-' + Math.random().toString(36).slice(2, 9);
container.innerHTML = `
<div class="csv-table-block ext-rendered" data-csv-id="${tableId}">
<div class="csv-toolbar">
<span class="csv-info">${data.length} row${data.length !== 1 ? 's' : ''} \u00d7 ${headers.length} col${headers.length !== 1 ? 's' : ''}</span>
<button class="csv-copy-btn" title="Copy as CSV">\ud83d\udccb Copy</button>
</div>
<div class="csv-table-scroll">
<table class="csv-table">
<thead>
<tr>${headers.map((h, i) => `<th data-col="${i}" title="Click to sort">${_escapeHtml(h)}<span class="csv-sort-icon"></span></th>`).join('')}</tr>
</thead>
<tbody>
${data.map(row => `<tr>${row.map(cell => `<td>${_escapeHtml(cell)}</td>`).join('')}</tr>`).join('')}
</tbody>
</table>
</div>
<details class="csv-source">
<summary>\ud83d\udcc4 View raw</summary>
<pre><code>${_escapeHtml(code.trim())}</code></pre>
</details>
</div>
`;
_attachHandlers(container, tableId, code.trim());
}
});
}
if (window.sw?._sdk) {
register();
} else {
document.addEventListener('sw:ready', register, { once: true });
}
})();

View File

@@ -4,106 +4,22 @@
// Renders ```diff code blocks with syntax-highlighted
// additions, deletions, hunk headers, and context lines.
// No external dependencies.
//
// Registers with sw.renderers via the sw:ready event.
// ==========================================
Extensions.register({
id: 'diff-viewer',
(function () {
'use strict';
async init(ctx) {
const self = this;
// ── Inject styles ──
this._injectStyles();
ctx.renderers.register('diff-block', {
type: 'block',
priority: 10,
pattern: 'diff',
render(lang, code, container) {
const lines = code.split('\n');
let stats = { added: 0, removed: 0 };
let currentFile = '';
const rendered = lines.map(line => {
const escaped = self._escapeHtml(line);
// File headers
if (line.startsWith('--- ') || line.startsWith('+++ ')) {
if (line.startsWith('+++ ')) {
currentFile = line.slice(4).trim();
}
return `<div class="diff-line diff-file-header">${escaped}</div>`;
}
// Hunk headers
if (line.startsWith('@@')) {
const hunkMatch = line.match(/^@@\s*-(\d+)(?:,\d+)?\s*\+(\d+)(?:,\d+)?\s*@@\s*(.*)/);
const context = hunkMatch ? hunkMatch[3] : '';
return `<div class="diff-line diff-hunk">${escaped}</div>`;
}
// Additions
if (line.startsWith('+')) {
stats.added++;
return `<div class="diff-line diff-add"><span class="diff-indicator">+</span>${self._escapeHtml(line.slice(1))}</div>`;
}
// Deletions
if (line.startsWith('-')) {
stats.removed++;
return `<div class="diff-line diff-del"><span class="diff-indicator">-</span>${self._escapeHtml(line.slice(1))}</div>`;
}
// Context (lines starting with space or no prefix)
if (line.startsWith(' ')) {
return `<div class="diff-line diff-ctx"><span class="diff-indicator"> </span>${self._escapeHtml(line.slice(1))}</div>`;
}
// Other (commit info, etc)
return `<div class="diff-line diff-meta">${escaped}</div>`;
}).join('');
const fileLabel = currentFile ? `<span class="diff-filename" title="${self._escapeHtml(currentFile)}">${self._escapeHtml(currentFile)}</span>` : '';
container.innerHTML = `
<div class="diff-block ext-rendered">
<div class="diff-toolbar">
${fileLabel}
<span class="diff-stats">
<span class="diff-stat-add">+${stats.added}</span>
<span class="diff-stat-del">-${stats.removed}</span>
</span>
<button class="diff-copy-btn" title="Copy diff">📋 Copy</button>
</div>
<div class="diff-content">${rendered}</div>
<details class="diff-source-toggle">
<summary>📝 View raw</summary>
<pre><code>${self._escapeHtml(code)}</code></pre>
</details>
</div>
`;
// Copy handler
const copyBtn = container.querySelector('.diff-copy-btn');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(code).then(() => {
copyBtn.textContent = '✓ Copied';
setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500);
}).catch(() => {});
});
}
}
});
},
_escapeHtml(str) {
function _escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
}
_injectStyles() {
// ── Styles ──────────────────────────────
function _injectStyles() {
if (document.getElementById('ext-style-diff-viewer')) return;
const style = document.createElement('style');
style.id = 'ext-style-diff-viewer';
@@ -158,9 +74,86 @@ Extensions.register({
max-height: 200px; overflow: auto;
}`;
document.head.appendChild(style);
},
destroy() {
document.getElementById('ext-style-diff-viewer')?.remove();
}
});
// ── Registration ────────────────────────
function register() {
if (!window.sw?.renderers) return;
_injectStyles();
sw.renderers.register('diff-block', {
type: 'block',
priority: 10,
pattern: 'diff',
render(lang, code, container) {
const lines = code.split('\n');
let stats = { added: 0, removed: 0 };
let currentFile = '';
const rendered = lines.map(line => {
const escaped = _escapeHtml(line);
if (line.startsWith('--- ') || line.startsWith('+++ ')) {
if (line.startsWith('+++ ')) currentFile = line.slice(4).trim();
return `<div class="diff-line diff-file-header">${escaped}</div>`;
}
if (line.startsWith('@@')) {
return `<div class="diff-line diff-hunk">${escaped}</div>`;
}
if (line.startsWith('+')) {
stats.added++;
return `<div class="diff-line diff-add"><span class="diff-indicator">+</span>${_escapeHtml(line.slice(1))}</div>`;
}
if (line.startsWith('-')) {
stats.removed++;
return `<div class="diff-line diff-del"><span class="diff-indicator">-</span>${_escapeHtml(line.slice(1))}</div>`;
}
if (line.startsWith(' ')) {
return `<div class="diff-line diff-ctx"><span class="diff-indicator"> </span>${_escapeHtml(line.slice(1))}</div>`;
}
return `<div class="diff-line diff-meta">${escaped}</div>`;
}).join('');
const fileLabel = currentFile
? `<span class="diff-filename" title="${_escapeHtml(currentFile)}">${_escapeHtml(currentFile)}</span>`
: '';
container.innerHTML = `
<div class="diff-block ext-rendered">
<div class="diff-toolbar">
${fileLabel}
<span class="diff-stats">
<span class="diff-stat-add">+${stats.added}</span>
<span class="diff-stat-del">-${stats.removed}</span>
</span>
<button class="diff-copy-btn" title="Copy diff">\ud83d\udccb Copy</button>
</div>
<div class="diff-content">${rendered}</div>
<details class="diff-source-toggle">
<summary>\ud83d\udcdd View raw</summary>
<pre><code>${_escapeHtml(code)}</code></pre>
</details>
</div>
`;
const copyBtn = container.querySelector('.diff-copy-btn');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(code).then(() => {
copyBtn.textContent = '\u2713 Copied';
setTimeout(() => { copyBtn.textContent = '\ud83d\udccb Copy'; }, 1500);
}).catch(() => {});
});
}
}
});
}
if (window.sw?._sdk) {
register();
} else {
document.addEventListener('sw:ready', register, { once: true });
}
})();

View File

@@ -65,9 +65,9 @@
T.assert(d.version === '0.31.1', 'version mismatch');
});
await T.test('crud', 'dashboardPackage', 'dashpkg: GET /admin/surfaces (dashboard in list)', async function () {
var d = await T.apiGet('/admin/surfaces');
T.assertHasKey(d, 'data', '/admin/surfaces');
await T.test('crud', 'dashboardPackage', 'dashpkg: GET /admin/packages (dashboard in list)', async function () {
var d = await T.apiGet('/admin/packages');
T.assertHasKey(d, 'data', '/admin/packages');
var found = d.data.some(function (s) { return s.id === 'dashboard'; });
T.assert(found, 'dashboard package should appear in surfaces list');
});

View File

@@ -68,9 +68,9 @@
T.assert(d.version === '0.31.0', 'version mismatch');
});
await T.test('crud', 'editorPackage', 'edpkg: GET /admin/surfaces (editor in list)', async function () {
var d = await T.apiGet('/admin/surfaces');
T.assertHasKey(d, 'data', '/admin/surfaces');
await T.test('crud', 'editorPackage', 'edpkg: GET /admin/packages (editor in list)', async function () {
var d = await T.apiGet('/admin/packages');
T.assertHasKey(d, 'data', '/admin/packages');
var found = d.data.some(function (s) { return s.id === 'editor'; });
T.assert(found, 'editor package should appear in surfaces list');
});
@@ -78,7 +78,7 @@
await T.test('crud', 'editorPackage', 'edpkg: core /editor removed (404)', async function () {
// The old /editor route should no longer exist — it was removed in CS1.
// We test by checking the surfaces list for a core editor entry.
var d = await T.apiGet('/admin/surfaces');
var d = await T.apiGet('/admin/packages');
var surfaces = d.data || [];
var coreEditor = surfaces.find(function (s) { return s.id === 'editor' && s.source === 'core'; });
T.assert(!coreEditor, 'no core editor surface should exist — editor is now a package');
@@ -139,7 +139,7 @@
await T.test('crud', 'editorPackage', 'edpkg: editor in extension nav items', async function () {
// When installed, editor should appear in the surfaces list as an extension surface
var d = await T.apiGet('/admin/surfaces');
var d = await T.apiGet('/admin/packages');
var surfaces = d.data || [];
var editorSurface = surfaces.find(function (s) { return s.id === 'editor'; });
T.assert(editorSurface, 'editor should be in surfaces list');

View File

@@ -20,7 +20,7 @@
var surfaceId = null;
await T.test('crud', 'surfaces', 'POST /admin/surfaces/install (happy path)', async function () {
await T.test('crud', 'surfaces', 'POST /admin/packages/install (happy path)', async function () {
surfaceId = 'icd-test-' + Date.now();
var manifest = JSON.stringify({
id: surfaceId,
@@ -28,32 +28,32 @@
route: '/s/' + surfaceId
});
var zipBlob = buildTestZip({ 'manifest.json': manifest });
await T.apiUpload('/admin/surfaces/install', zipBlob, surfaceId + '.surface');
await T.apiUpload('/admin/packages/install', zipBlob, surfaceId + '.surface');
// Verify it exists
var d = await T.apiGet('/admin/surfaces/' + surfaceId);
var d = await T.apiGet('/admin/packages/' + surfaceId);
T.assert(d.id === surfaceId, 'installed surface id mismatch');
T.assert(d.title === 'ICD Test Surface', 'title mismatch');
T.registerCleanup(async function () {
if (surfaceId) {
var token = await T.getAuthToken();
return T.authFetch(token, 'DELETE', '/admin/surfaces/' + surfaceId);
return T.authFetch(token, 'DELETE', '/admin/packages/' + surfaceId);
}
});
});
if (surfaceId) {
await T.test('crud', 'surfaces', 'GET /admin/surfaces/:id (read)', async function () {
var d = await T.apiGet('/admin/surfaces/' + surfaceId);
await T.test('crud', 'surfaces', 'GET /admin/packages/:id (read)', async function () {
var d = await T.apiGet('/admin/packages/' + surfaceId);
T.assertShape(d, T.S.surfaceAdmin, 'surface detail');
T.assert(d.id === surfaceId, 'id mismatch');
T.assert(d.source === 'extension', 'source should be extension');
T.assert(d.enabled === true, 'newly installed surface should be enabled');
});
await T.test('crud', 'surfaces', 'GET /admin/surfaces (list includes new)', async function () {
var d = await T.apiGet('/admin/surfaces');
T.assertHasKey(d, 'data', '/admin/surfaces');
await T.test('crud', 'surfaces', 'GET /admin/packages (list includes new)', async function () {
var d = await T.apiGet('/admin/packages');
T.assertHasKey(d, 'data', '/admin/packages');
var found = d.data.some(function (s) { return s.id === surfaceId; });
T.assert(found, 'installed surface should appear in admin list');
});
@@ -70,9 +70,9 @@
T.assert(entry.enabled === undefined, 'nav entry should NOT have enabled');
});
await T.test('crud', 'surfaces', 'PUT /admin/surfaces/:id/disable', async function () {
await T.test('crud', 'surfaces', 'PUT /admin/packages/:id/disable', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
'/admin/surfaces/' + surfaceId + '/disable');
'/admin/packages/' + surfaceId + '/disable');
T.assertStatus(d, 200, 'disable');
T.assert(d.enabled === false, 'should report disabled');
});
@@ -83,16 +83,16 @@
T.assert(!found, 'disabled surface should NOT appear in user nav list');
});
await T.test('crud', 'surfaces', 'GET /admin/surfaces (disabled still in admin)', async function () {
var d = await T.apiGet('/admin/surfaces');
await T.test('crud', 'surfaces', 'GET /admin/packages (disabled still in admin)', async function () {
var d = await T.apiGet('/admin/packages');
var entry = d.data.find(function (s) { return s.id === surfaceId; });
T.assert(entry, 'disabled surface should still appear in admin list');
T.assert(entry.enabled === false, 'should show as disabled in admin list');
});
await T.test('crud', 'surfaces', 'PUT /admin/surfaces/:id/enable', async function () {
await T.test('crud', 'surfaces', 'PUT /admin/packages/:id/enable', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
'/admin/surfaces/' + surfaceId + '/enable');
'/admin/packages/' + surfaceId + '/enable');
T.assertStatus(d, 200, 'enable');
T.assert(d.enabled === true, 'should report enabled');
});
@@ -103,53 +103,53 @@
T.assert(found, 're-enabled surface should appear in user nav list');
});
await T.test('crud', 'surfaces', 'PUT /admin/surfaces/chat/disable (reject → 400)', async function () {
await T.test('crud', 'surfaces', 'PUT /admin/packages/chat/disable (reject → 400)', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
'/admin/surfaces/chat/disable');
'/admin/packages/chat/disable');
T.assert(d._status === 400, 'disabling chat should return 400, got ' + d._status);
});
await T.test('crud', 'surfaces', 'PUT /admin/surfaces/admin/disable (reject → 400)', async function () {
await T.test('crud', 'surfaces', 'PUT /admin/packages/admin/disable (reject → 400)', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
'/admin/surfaces/admin/disable');
'/admin/packages/admin/disable');
T.assert(d._status === 400, 'disabling admin should return 400, got ' + d._status);
});
await T.test('crud', 'surfaces', 'DELETE /admin/surfaces/chat (core reject → 400)', async function () {
await T.test('crud', 'surfaces', 'DELETE /admin/packages/chat (core reject → 400)', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'DELETE',
'/admin/surfaces/chat');
'/admin/packages/chat');
T.assert(d._status === 400, 'deleting core surface should return 400, got ' + d._status);
});
await T.test('crud', 'surfaces', 'GET /admin/surfaces/:id (not found → 404)', async function () {
await T.test('crud', 'surfaces', 'GET /admin/packages/:id (not found → 404)', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'GET',
'/admin/surfaces/nonexistent-surface-id');
'/admin/packages/nonexistent-surface-id');
T.assert(d._status === 404, 'nonexistent should return 404, got ' + d._status);
});
await T.test('crud', 'surfaces', 'PUT /admin/surfaces/nonexistent/disable (404)', async function () {
await T.test('crud', 'surfaces', 'PUT /admin/packages/nonexistent/disable (404)', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
'/admin/surfaces/nonexistent-surface-id/disable');
'/admin/packages/nonexistent-surface-id/disable');
T.assert(d._status === 404, 'disable nonexistent should return 404, got ' + d._status);
});
await T.test('crud', 'surfaces', 'POST /admin/surfaces/install (missing manifest → 400)', async function () {
await T.test('crud', 'surfaces', 'POST /admin/packages/install (missing manifest → 400)', async function () {
var zipBlob = buildTestZip({ 'readme.txt': 'no manifest here' });
var d;
try {
d = await T.apiUpload('/admin/surfaces/install', zipBlob, 'bad.surface');
d = await T.apiUpload('/admin/packages/install', zipBlob, 'bad.surface');
T.assert(false, 'should have thrown');
} catch (e) {
T.assert(e.message.indexOf('400') !== -1, 'missing manifest should 400: ' + e.message);
}
});
await T.test('crud', 'surfaces', 'POST /admin/surfaces/install (core conflict → 409)', async function () {
await T.test('crud', 'surfaces', 'POST /admin/packages/install (core conflict → 409)', async function () {
var manifest = JSON.stringify({ id: 'chat', title: 'Evil Chat', route: '/s/chat' });
var zipBlob = buildTestZip({ 'manifest.json': manifest });
var d;
try {
d = await T.apiUpload('/admin/surfaces/install', zipBlob, 'chat.surface');
d = await T.apiUpload('/admin/packages/install', zipBlob, 'chat.surface');
// If no throw, check status manually
T.assert(false, 'overwriting core surface should have thrown');
} catch (e) {
@@ -157,10 +157,10 @@
}
});
await T.test('crud', 'surfaces', 'POST /admin/surfaces/install (reseed preserves enabled)', async function () {
await T.test('crud', 'surfaces', 'POST /admin/packages/install (reseed preserves enabled)', async function () {
// Surface was re-enabled above. Disable it, then re-install.
await T.authFetch(await T.getAuthToken(), 'PUT',
'/admin/surfaces/' + surfaceId + '/disable');
'/admin/packages/' + surfaceId + '/disable');
// Re-install same ID
var manifest = JSON.stringify({
@@ -169,33 +169,33 @@
route: '/s/' + surfaceId
});
var zipBlob = buildTestZip({ 'manifest.json': manifest });
await T.apiUpload('/admin/surfaces/install', zipBlob, surfaceId + '.surface');
await T.apiUpload('/admin/packages/install', zipBlob, surfaceId + '.surface');
// Verify title updated but enabled preserved as false
var d = await T.apiGet('/admin/surfaces/' + surfaceId);
var d = await T.apiGet('/admin/packages/' + surfaceId);
T.assert(d.title === 'ICD Test Surface v2', 'title should be updated after reseed');
T.assert(d.enabled === false, 'enabled should be preserved as false after reseed');
// Re-enable for cleanup
await T.authFetch(await T.getAuthToken(), 'PUT',
'/admin/surfaces/' + surfaceId + '/enable');
'/admin/packages/' + surfaceId + '/enable');
});
await T.test('crud', 'surfaces', 'DELETE /admin/surfaces/:id', async function () {
await T.test('crud', 'surfaces', 'DELETE /admin/packages/:id', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'DELETE', '/admin/surfaces/' + surfaceId);
var d = await T.authFetch(token, 'DELETE', '/admin/packages/' + surfaceId);
T.assertStatus(d, 200, 'delete');
T.assert(d.deleted === true, 'should report deleted');
// Verify gone
var check = await T.authFetch(token, 'GET', '/admin/surfaces/' + surfaceId);
var check = await T.authFetch(token, 'GET', '/admin/packages/' + surfaceId);
T.assert(check._status === 404, 'deleted surface should 404 on get');
surfaceId = null;
});
await T.test('crud', 'surfaces', 'DELETE /admin/surfaces/nonexistent (404)', async function () {
await T.test('crud', 'surfaces', 'DELETE /admin/packages/nonexistent (404)', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'DELETE',
'/admin/surfaces/nonexistent-surface-id');
'/admin/packages/nonexistent-surface-id');
T.assert(d._status === 404, 'delete nonexistent should return 404, got ' + d._status);
});
}

View File

@@ -227,7 +227,7 @@
};
// ─── API Wrappers ───────────────────────────────────────────
// v0.37.14: raw fetch — old API._* globals removed in scorched earth.
// Raw fetch — kernel provides no global API wrappers; extensions use fetch directly.
async function _fetchJSON(method, path, body) {
var token = await T.getAuthToken();

View File

@@ -28,7 +28,7 @@
var adminGets = [
'/admin/stats', '/admin/users', '/admin/settings',
'/admin/configs', '/admin/models', '/admin/personas',
'/admin/teams', '/admin/groups', '/admin/surfaces',
'/admin/teams', '/admin/groups', '/admin/packages',
'/admin/providers/health', '/admin/routing/policies',
'/admin/permissions', '/admin/roles', '/admin/vault/status',
'/admin/storage/status', '/admin/audit', '/admin/pricing',
@@ -72,8 +72,8 @@
'expected 403/401, got ' + d._status);
});
await T.test('authz', 'admin-deny', 'user → POST /admin/surfaces/install (expect 403)', async function () {
var d = await T.authFetch(testUser.token, 'POST', '/admin/surfaces/install', {});
await T.test('authz', 'admin-deny', 'user → POST /admin/packages/install (expect 403)', async function () {
var d = await T.authFetch(testUser.token, 'POST', '/admin/packages/install', {});
T.assert(d._status === 403 || d._status === 401 || d._status === 400,
'expected 403/401/400, got ' + d._status);
});

View File

@@ -543,7 +543,7 @@
await T.test('security', 'input-validation', '[P0] path traversal in surface archive', async function () {
var blob = new Blob([JSON.stringify({ id: '../../../etc/evil', title: 'Path Traversal Test' })], { type: 'application/json' });
try {
var d = await T.apiUpload('/admin/surfaces/install', blob, 'evil.surface');
var d = await T.apiUpload('/admin/packages/install', blob, 'evil.surface');
T.assert(d._status === 400 || d._status === 409,
'path traversal surface accepted! got ' + d._status);
} catch (e) {
@@ -644,8 +644,8 @@
assertDenied(d._status, 'CRITICAL: team admin can delete users');
});
await T.test('security', 'escalation', '[P0] teamAdmin → POST /admin/surfaces/install', async function () {
var d = await T.authFetch(teamAdmin.token, 'POST', '/admin/surfaces/install', {});
await T.test('security', 'escalation', '[P0] teamAdmin → POST /admin/packages/install', async function () {
var d = await T.authFetch(teamAdmin.token, 'POST', '/admin/packages/install', {});
if (d._status === 400) return; // bad upload format, not a security issue
assertDenied(d._status, 'team admin can install surfaces');
});

View File

@@ -326,9 +326,9 @@
T.assertHasKey(d, 'data', '/admin/groups');
});
await T.test('smoke', 'admin', 'GET /admin/surfaces', async function () {
var d = await T.apiGet('/admin/surfaces');
T.assertHasKey(d, 'data', '/admin/surfaces');
await T.test('smoke', 'admin', 'GET /admin/packages', async function () {
var d = await T.apiGet('/admin/packages');
T.assertHasKey(d, 'data', '/admin/packages');
T.assert(Array.isArray(d.data), 'surfaces should be array');
if (d.data.length > 0) {
T.assertShape(d.data[0], T.S.surfaceAdmin, 'surfaces[0]');

View File

@@ -3,177 +3,141 @@
// ==========================================
// Renders ```latex / ```math code blocks and inline $...$ / $$...$$ syntax.
// Loads KaTeX dynamically on first use.
//
// Registers with sw.renderers via the sw:ready event.
// ==========================================
Extensions.register({
id: 'katex-renderer',
(function () {
'use strict';
_katexReady: false,
_katexLoading: null,
let _katexReady = false;
let _katexLoading = null;
async init(ctx) {
const self = this;
// ── Inject styles ──
this._injectStyles();
// ── Block renderer: match ```latex or ```math ──
ctx.renderers.register('katex-block', {
type: 'block',
priority: 10,
match(lang) {
const l = (lang || '').toLowerCase();
return l === 'latex' || l === 'math' || l === 'tex' || l === 'katex';
},
render(lang, code, container) {
const id = 'katex-' + Math.random().toString(36).slice(2, 9);
container.innerHTML = `
<div class="katex-block ext-rendered" data-katex-id="${id}">
<div class="katex-display-container" data-katex-src="${encodeURIComponent(code.trim())}" data-katex-display="true">
<div class="katex-loading">
<span class="katex-spinner"></span> Rendering math…
</div>
</div>
<details class="katex-source">
<summary>𝑓(𝑥) View source</summary>
<pre><code class="language-latex">${self._escapeHtml(code.trim())}</code></pre>
</details>
</div>
`;
}
});
// ── Post renderer: render KaTeX blocks + find inline math ──
ctx.renderers.register('katex-post', {
type: 'post',
priority: 10,
render(container) {
// Render code blocks with data-katex-src
const blocks = container.querySelectorAll('.katex-display-container[data-katex-src]');
if (blocks.length > 0) {
blocks.forEach(el => {
if (el.dataset.rendered) return;
el.dataset.rendered = 'pending';
self._renderBlock(el);
});
function _escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Process inline math in text content
self._processInlineMath(container);
// ── KaTeX Library Loading ──────────────
function _loadKaTeX() {
if (_katexReady) return Promise.resolve();
if (_katexLoading) return _katexLoading;
_katexLoading = new Promise((resolve, reject) => {
if (typeof katex !== 'undefined') {
_katexReady = true;
resolve();
return;
}
const base = (window.__BASE__ || '');
const localCSS = `${base}/vendor/katex/katex.min.css`;
const localJS = `${base}/vendor/katex/katex.min.js`;
const cdnCSS = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css';
const cdnJS = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js';
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = localCSS;
link.onerror = () => { link.href = cdnCSS; };
document.head.appendChild(link);
const script = document.createElement('script');
script.src = localJS;
script.onload = () => { _katexReady = true; resolve(); };
script.onerror = () => {
console.warn('[KaTeX] Local vendor not found, trying CDN');
const cdn = document.createElement('script');
cdn.src = cdnJS;
cdn.onload = () => { _katexReady = true; resolve(); };
cdn.onerror = () => {
console.error('[KaTeX] Failed to load from both local and CDN');
reject(new Error('Failed to load KaTeX'));
};
document.head.appendChild(cdn);
};
document.head.appendChild(script);
});
return _katexLoading;
}
// Pre-load KaTeX
this._loadKaTeX();
},
// ── Block Rendering ──────────────────────
async _renderBlock(el) {
async function _renderBlock(el) {
const code = decodeURIComponent(el.dataset.katexSrc);
const displayMode = el.dataset.katexDisplay === 'true';
try {
await this._loadKaTeX();
await _loadKaTeX();
el.innerHTML = katex.renderToString(code, {
displayMode,
throwOnError: false,
trust: false,
strict: false,
displayMode, throwOnError: false, trust: false, strict: false,
});
el.dataset.rendered = 'true';
} catch (e) {
el.innerHTML = `
<div class="katex-error">
<strong>Math error:</strong> ${this._escapeHtml(e.message || String(e))}
<strong>Math error:</strong> ${_escapeHtml(e.message || String(e))}
</div>
`;
el.dataset.rendered = 'error';
}
},
async _processInlineMath(container) {
// Skip if no $ signs present at all
if (!container.textContent || !container.textContent.includes('$')) return;
try {
await this._loadKaTeX();
} catch {
return;
}
// Walk text nodes, skip code/pre/katex-already-rendered
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
{
// ── Inline Math Processing ───────────────
async function _processInlineMath(container) {
if (!container.textContent || !container.textContent.includes('$')) return;
try { await _loadKaTeX(); } catch { return; }
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
// Skip code, pre, already-rendered katex, and script elements
const tag = parent.tagName;
if (tag === 'CODE' || tag === 'PRE' || tag === 'SCRIPT' ||
tag === 'TEXTAREA' || tag === 'STYLE') {
tag === 'TEXTAREA' || tag === 'STYLE') return NodeFilter.FILTER_REJECT;
if (parent.closest('.katex, .katex-block, .katex-display-container, code, pre'))
return NodeFilter.FILTER_REJECT;
}
if (parent.closest('.katex, .katex-block, .katex-display-container, code, pre')) {
return NodeFilter.FILTER_REJECT;
}
if (!node.textContent.includes('$')) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
}
);
});
const textNodes = [];
let node;
while (node = walker.nextNode()) textNodes.push(node);
for (const textNode of textNodes) {
this._replaceInlineMath(textNode);
for (const textNode of textNodes) _replaceInlineMath(textNode);
}
},
_replaceInlineMath(textNode) {
function _replaceInlineMath(textNode) {
const text = textNode.textContent;
// Match $$...$$ (display) and $...$ (inline), non-greedy
// Avoid matching escaped \$ or empty $$
const pattern = /\$\$([^$]+?)\$\$|\$([^$\n]+?)\$/g;
let match;
const parts = [];
let lastIndex = 0;
while ((match = pattern.exec(text)) !== null) {
// Text before the match
if (match.index > lastIndex) {
parts.push({ type: 'text', value: text.slice(lastIndex, match.index) });
}
const displayMode = !!match[1]; // $$ ... $$
const displayMode = !!match[1];
const expr = match[1] || match[2];
try {
const html = katex.renderToString(expr.trim(), {
displayMode,
throwOnError: false,
trust: false,
strict: false,
displayMode, throwOnError: false, trust: false, strict: false,
});
parts.push({ type: 'katex', value: html, displayMode });
} catch {
// Leave as-is on error
parts.push({ type: 'text', value: match[0] });
}
lastIndex = match.index + match[0].length;
}
if (parts.length === 0) return; // No math found
if (parts.length === 0) return;
if (lastIndex < text.length) parts.push({ type: 'text', value: text.slice(lastIndex) });
// Remaining text after last match
if (lastIndex < text.length) {
parts.push({ type: 'text', value: text.slice(lastIndex) });
}
// Replace text node with a span containing the rendered parts
const span = document.createElement('span');
span.className = 'katex-inline-container';
for (const part of parts) {
@@ -186,70 +150,12 @@ Extensions.register({
span.appendChild(wrapper);
}
}
textNode.parentNode.replaceChild(span, textNode);
},
_loadKaTeX() {
if (this._katexReady) return Promise.resolve();
if (this._katexLoading) return this._katexLoading;
this._katexLoading = new Promise((resolve, reject) => {
if (typeof katex !== 'undefined') {
this._katexReady = true;
resolve();
return;
}
const base = (window.__BASE__ || '');
const localCSS = `${base}/vendor/katex/katex.min.css`;
const localJS = `${base}/vendor/katex/katex.min.js`;
const cdnCSS = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css';
const cdnJS = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js';
// ── Styles ──────────────────────────────
// Load CSS first
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = localCSS;
link.onerror = () => {
link.href = cdnCSS;
};
document.head.appendChild(link);
// Load JS
const script = document.createElement('script');
script.src = localJS;
script.onload = () => {
this._katexReady = true;
resolve();
};
script.onerror = () => {
console.warn('[KaTeX] Local vendor not found, trying CDN');
const cdn = document.createElement('script');
cdn.src = cdnJS;
cdn.onload = () => {
this._katexReady = true;
resolve();
};
cdn.onerror = () => {
console.error('[KaTeX] Failed to load from both local and CDN');
reject(new Error('Failed to load KaTeX'));
};
document.head.appendChild(cdn);
};
document.head.appendChild(script);
});
return this._katexLoading;
},
_escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
_injectStyles() {
function _injectStyles() {
if (document.getElementById('ext-style-katex-renderer')) return;
const style = document.createElement('style');
style.id = 'ext-style-katex-renderer';
@@ -289,9 +195,64 @@ Extensions.register({
.katex-inline .katex { font-size: 1.05em; }
.katex-display { display: block; text-align: center; margin: 8px 0; }`;
document.head.appendChild(style);
},
destroy() {
document.getElementById('ext-style-katex-renderer')?.remove();
}
});
// ── Registration ────────────────────────
function register() {
if (!window.sw?.renderers) return;
_injectStyles();
// Block renderer: match ```latex, ```math, ```tex, ```katex
sw.renderers.register('katex-block', {
type: 'block',
priority: 10,
match(lang) {
const l = (lang || '').toLowerCase();
return l === 'latex' || l === 'math' || l === 'tex' || l === 'katex';
},
render(lang, code, container) {
const id = 'katex-' + Math.random().toString(36).slice(2, 9);
container.innerHTML = `
<div class="katex-block ext-rendered" data-katex-id="${id}">
<div class="katex-display-container" data-katex-src="${encodeURIComponent(code.trim())}" data-katex-display="true">
<div class="katex-loading">
<span class="katex-spinner"></span> Rendering math\u2026
</div>
</div>
<details class="katex-source">
<summary>\ud835\udc53(\ud835\udc65) View source</summary>
<pre><code class="language-latex">${_escapeHtml(code.trim())}</code></pre>
</details>
</div>
`;
}
});
// Post renderer: render KaTeX blocks + find inline math
sw.renderers.register('katex-post', {
type: 'post',
priority: 10,
render(container) {
const blocks = container.querySelectorAll('.katex-display-container[data-katex-src]');
if (blocks.length > 0) {
blocks.forEach(el => {
if (el.dataset.rendered) return;
el.dataset.rendered = 'pending';
_renderBlock(el);
});
}
_processInlineMath(container);
}
});
_loadKaTeX();
}
if (window.sw?._sdk) {
register();
} else {
document.addEventListener('sw:ready', register, { once: true });
}
})();

View File

@@ -2,94 +2,33 @@
// Mermaid Diagram Renderer — Browser Extension
// ==========================================
// Renders ```mermaid code blocks as interactive SVG diagrams.
// Features: viewBox-based zoom/pan, context-aware expand (fullscreen
// or side-panel pop-out), SVG/PNG export, source copy.
// Features: viewBox-based zoom/pan, fullscreen expand,
// SVG/PNG export, source copy.
// Loads mermaid.js dynamically on first use.
//
// Uses ctx.ui primitives from the host app for toast notifications,
// side-panel preview, theme detection, and mobile awareness.
// Registers with sw.renderers via the sw:ready event.
// ==========================================
Extensions.register({
id: 'mermaid-renderer',
(function () {
'use strict';
_mermaidReady: false,
_mermaidLoading: null,
_ctx: null,
let _mermaidReady = false;
let _mermaidLoading = null;
async init(ctx) {
const self = this;
this._ctx = ctx;
this._injectStyles();
// ── Block renderer: match ```mermaid ──
ctx.renderers.register('mermaid', {
type: 'block',
pattern: 'mermaid',
priority: 10,
render(lang, code, container) {
const id = 'mmd-' + Math.random().toString(36).slice(2, 9);
container.innerHTML = `
<div class="mermaid-block" data-mermaid-id="${id}">
<div class="mermaid-toolbar">
<span class="mermaid-title">\u{1f4ca} Diagram</span>
<span class="mermaid-zoom-label" data-zoom-label="${id}">100%</span>
<div class="mermaid-toolbar-btns">
<button class="mmd-btn" data-action="zoom-in" data-target="${id}" title="Zoom in">+</button>
<button class="mmd-btn" data-action="zoom-out" data-target="${id}" title="Zoom out">\u2212</button>
<button class="mmd-btn" data-action="zoom-fit" data-target="${id}" title="Fit to view">\u22a1</button>
<button class="mmd-btn" data-action="zoom-reset" data-target="${id}" title="Reset zoom">1:1</button>
<span class="mmd-sep"></span>
<button class="mmd-btn" data-action="expand" data-target="${id}" title="Expand">\u26f6</button>
<span class="mmd-sep"></span>
<button class="mmd-btn" data-action="export-svg" data-target="${id}" title="Download SVG">SVG</button>
<button class="mmd-btn" data-action="export-png" data-target="${id}" title="Download PNG">PNG</button>
</div>
</div>
<div class="mermaid-viewport" data-viewport="${id}">
<div class="mermaid-diagram" data-mermaid-src="${encodeURIComponent(code.trim())}" data-diagram="${id}">
<div class="mermaid-loading">
<span class="mermaid-spinner"></span> Rendering diagram\u2026
</div>
</div>
</div>
<details class="mermaid-source">
<summary>
<span>\u{1f4cb} View source</span>
<button class="mmd-btn mmd-copy-src" data-action="copy-src" data-target="${id}" title="Copy source" onclick="event.stopPropagation()">Copy</button>
</summary>
<pre><code class="language-mermaid" data-source="${id}">${self._escapeHtml(code.trim())}</code></pre>
</details>
</div>
`;
function _isDark() {
return document.documentElement.dataset.theme === 'dark' ||
document.documentElement.classList.contains('dark');
}
});
// ── Post renderer: render diagrams + wire interactivity ──
ctx.renderers.register('mermaid-post', {
type: 'post',
priority: 10,
render(container) {
const diagrams = container.querySelectorAll('.mermaid-diagram[data-mermaid-src]');
if (diagrams.length === 0) return;
diagrams.forEach(el => {
if (el.dataset.rendered) return;
el.dataset.rendered = 'pending';
self._renderDiagram(el);
});
self._wireToolbar(container);
function _escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
});
this._loadMermaid();
},
// ── ViewBox Zoom/Pan State ──────────────
_getState(id) {
function _getState(id) {
const vp = document.querySelector(`[data-viewport="${id}"]`);
if (!vp) return null;
if (!vp._mmdState) {
@@ -102,10 +41,10 @@ Extensions.register({
};
}
return vp._mmdState;
},
}
_initState(id) {
const state = this._getState(id);
function _initState(id) {
const state = _getState(id);
if (!state) return;
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
if (!svg) return;
@@ -113,69 +52,51 @@ Extensions.register({
const vb = svg.getAttribute('viewBox');
if (!vb) {
const bbox = svg.getBBox();
state.natX = bbox.x;
state.natY = bbox.y;
state.natW = bbox.width;
state.natH = bbox.height;
state.natX = bbox.x; state.natY = bbox.y;
state.natW = bbox.width; state.natH = bbox.height;
} else {
const parts = vb.trim().split(/[\s,]+/).map(Number);
state.natX = parts[0] || 0;
state.natY = parts[1] || 0;
state.natW = parts[2] || 0;
state.natH = parts[3] || 0;
state.natX = parts[0] || 0; state.natY = parts[1] || 0;
state.natW = parts[2] || 0; state.natH = parts[3] || 0;
}
state.vbX = state.natX;
state.vbY = state.natY;
state.vbW = state.natW;
state.vbH = state.natH;
state.vbX = state.natX; state.vbY = state.natY;
state.vbW = state.natW; state.vbH = state.natH;
state.ready = true;
_applyViewBox(id);
}
this._applyViewBox(id);
},
_applyViewBox(id) {
const state = this._getState(id);
function _applyViewBox(id) {
const state = _getState(id);
if (!state || !state.ready) return;
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
if (!svg) return;
svg.setAttribute('viewBox', `${state.vbX} ${state.vbY} ${state.vbW} ${state.vbH}`);
const zoomPct = Math.round((state.natW / state.vbW) * 100);
const label = document.querySelector(`[data-zoom-label="${id}"]`);
if (label) label.textContent = zoomPct + '%';
},
}
_zoom(id, factor) {
const state = this._getState(id);
function _zoom(id, factor) {
const state = _getState(id);
if (!state || !state.ready) return;
const scale = 1 / (1 + factor);
const newW = state.vbW * scale;
const newH = state.vbH * scale;
const minW = state.natW / 20;
const maxW = state.natW * 2;
if (newW < minW || newW > maxW) return;
if (newW < state.natW / 20 || newW > state.natW * 2) return;
const cx = state.vbX + state.vbW / 2;
const cy = state.vbY + state.vbH / 2;
state.vbW = newW; state.vbH = newH;
state.vbX = cx - newW / 2; state.vbY = cy - newH / 2;
_applyViewBox(id);
}
state.vbW = newW;
state.vbH = newH;
state.vbX = cx - newW / 2;
state.vbY = cy - newH / 2;
this._applyViewBox(id);
},
_zoomAt(id, factor, clientX, clientY) {
const state = this._getState(id);
function _zoomAt(id, factor, clientX, clientY) {
const state = _getState(id);
if (!state || !state.ready) return;
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
if (!svg) return;
const svgRect = svg.getBoundingClientRect();
if (svgRect.width === 0 || svgRect.height === 0) return;
@@ -187,147 +108,76 @@ Extensions.register({
const scale = 1 / (1 + factor);
const newW = state.vbW * scale;
const newH = state.vbH * scale;
if (newW < state.natW / 20 || newW > state.natW * 2) return;
state.vbW = newW; state.vbH = newH;
state.vbX = pointX - fx * newW; state.vbY = pointY - fy * newH;
_applyViewBox(id);
}
const minW = state.natW / 20;
const maxW = state.natW * 2;
if (newW < minW || newW > maxW) return;
state.vbW = newW;
state.vbH = newH;
state.vbX = pointX - fx * newW;
state.vbY = pointY - fy * newH;
this._applyViewBox(id);
},
_zoomReset(id) {
const state = this._getState(id);
function _zoomReset(id) {
const state = _getState(id);
if (!state || !state.ready) return;
state.vbX = state.natX;
state.vbY = state.natY;
state.vbW = state.natW;
state.vbH = state.natH;
this._applyViewBox(id);
},
state.vbX = state.natX; state.vbY = state.natY;
state.vbW = state.natW; state.vbH = state.natH;
_applyViewBox(id);
}
_zoomFit(id) {
const state = this._getState(id);
function _zoomFit(id) {
const state = _getState(id);
if (!state || !state.ready) return;
const vp = document.querySelector(`[data-viewport="${id}"]`);
if (!vp) return;
const vpRect = vp.getBoundingClientRect();
if (vpRect.width === 0 || vpRect.height === 0) return;
const vpAspect = vpRect.width / vpRect.height;
const natAspect = state.natW / state.natH;
if (vpAspect > natAspect) {
const newW = state.natH * vpAspect;
state.vbX = state.natX - (newW - state.natW) / 2;
state.vbY = state.natY;
state.vbW = newW;
state.vbH = state.natH;
state.vbW = newW; state.vbH = state.natH;
} else {
const newH = state.natW / vpAspect;
state.vbX = state.natX;
state.vbY = state.natY - (newH - state.natH) / 2;
state.vbW = state.natW;
state.vbH = newH;
state.vbW = state.natW; state.vbH = newH;
}
this._applyViewBox(id);
},
// ── Expand (context-aware) ──────────────
// Side panel open → pop out into it (replaces current content).
// Side panel closed → fullscreen overlay.
_expand(id) {
if (this._ctx.ui.isPanelOpen()) {
this._popOutToPanel(id);
} else {
this._toggleFullscreen(id);
_applyViewBox(id);
}
},
// ── Fullscreen ──────────────────────────
_toggleFullscreen(id) {
function _toggleFullscreen(id) {
const block = document.querySelector(`[data-mermaid-id="${id}"]`);
if (!block) return;
const isFS = block.classList.toggle('mermaid-fullscreen');
if (isFS) {
document.body.style.overflow = 'hidden';
// Close button overlay (always visible — critical for mobile)
const closeBtn = document.createElement('button');
closeBtn.className = 'mmd-fullscreen-close';
closeBtn.innerHTML = '\u2715';
closeBtn.title = 'Close fullscreen';
closeBtn.addEventListener('click', () => this._toggleFullscreen(id));
closeBtn.addEventListener('click', () => _toggleFullscreen(id));
block.appendChild(closeBtn);
// Escape listener (desktop convenience, not sole exit)
block._mmdEscHandler = (e) => {
if (e.key === 'Escape') this._toggleFullscreen(id);
};
block._mmdEscHandler = (e) => { if (e.key === 'Escape') _toggleFullscreen(id); };
document.addEventListener('keydown', block._mmdEscHandler);
requestAnimationFrame(() => this._zoomFit(id));
requestAnimationFrame(() => _zoomFit(id));
} else {
document.body.style.overflow = '';
const closeBtn = block.querySelector('.mmd-fullscreen-close');
if (closeBtn) closeBtn.remove();
if (block._mmdEscHandler) {
document.removeEventListener('keydown', block._mmdEscHandler);
delete block._mmdEscHandler;
}
}
},
// ── Side Panel Pop-out ──────────────────
_popOutToPanel(id) {
const diagram = document.querySelector(`[data-diagram="${id}"]`);
const svg = diagram?.querySelector('svg');
if (!svg) return;
const state = this._getState(id);
const clone = svg.cloneNode(true);
if (state?.ready) {
clone.setAttribute('viewBox',
`${state.natX} ${state.natY} ${state.natW} ${state.natH}`);
}
clone.setAttribute('width', '100%');
clone.removeAttribute('height');
clone.setAttribute('preserveAspectRatio', 'xMidYMid meet');
const isDark = this._ctx.ui.isDark();
const html = `<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<style>
body { margin: 0; padding: 16px; display: flex; justify-content: center;
align-items: flex-start; min-height: 100vh;
background: ${isDark ? '#1a1a2e' : '#fff'};
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
svg { max-width: 100%; height: auto; }
</style>
</head><body>${clone.outerHTML}</body></html>`;
this._ctx.ui.openPreview(html);
},
// ── Toolbar Wiring ──────────────────────
_wireToolbar(container) {
const self = this;
function _wireToolbar(container) {
if (container._mmdWired) return;
container._mmdWired = true;
@@ -339,14 +189,14 @@ Extensions.register({
if (!id) return;
switch (action) {
case 'zoom-in': self._zoom(id, 0.25); break;
case 'zoom-out': self._zoom(id, -0.2); break;
case 'zoom-reset': self._zoomReset(id); break;
case 'zoom-fit': self._zoomFit(id); break;
case 'expand': self._expand(id); break;
case 'export-svg': self._exportSVG(id); break;
case 'export-png': self._exportPNG(id); break;
case 'copy-src': self._copySource(id); break;
case 'zoom-in': _zoom(id, 0.25); break;
case 'zoom-out': _zoom(id, -0.2); break;
case 'zoom-reset': _zoomReset(id); break;
case 'zoom-fit': _zoomFit(id); break;
case 'expand': _toggleFullscreen(id); break;
case 'export-svg': _exportSVG(id); break;
case 'export-png': _exportPNG(id); break;
case 'copy-src': _copySource(id); break;
}
});
@@ -359,42 +209,35 @@ Extensions.register({
vp.addEventListener('wheel', (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
self._zoomAt(id, delta, e.clientX, e.clientY);
_zoomAt(id, delta, e.clientX, e.clientY);
}, { passive: false });
vp.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
const state = self._getState(id);
const state = _getState(id);
if (!state || !state.ready) return;
state.dragging = true;
state.startX = e.clientX;
state.startY = e.clientY;
state.startVbX = state.vbX;
state.startVbY = state.vbY;
state.startX = e.clientX; state.startY = e.clientY;
state.startVbX = state.vbX; state.startVbY = state.vbY;
vp.classList.add('mmd-grabbing');
e.preventDefault();
});
vp.addEventListener('mousemove', (e) => {
const state = self._getState(id);
const state = _getState(id);
if (!state?.dragging) return;
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
if (!svg) return;
const svgRect = svg.getBoundingClientRect();
if (svgRect.width === 0) return;
const pxToVb = state.vbW / svgRect.width;
const dx = (e.clientX - state.startX) * pxToVb;
const dy = (e.clientY - state.startY) * pxToVb;
state.vbX = state.startVbX - dx;
state.vbY = state.startVbY - dy;
self._applyViewBox(id);
state.vbX = state.startVbX - (e.clientX - state.startX) * pxToVb;
state.vbY = state.startVbY - (e.clientY - state.startY) * pxToVb;
_applyViewBox(id);
});
const endDrag = () => {
const state = self._getState(id);
const state = _getState(id);
if (!state) return;
state.dragging = false;
vp.classList.remove('mmd-grabbing');
@@ -403,9 +246,8 @@ Extensions.register({
vp.addEventListener('mouseleave', endDrag);
let lastTouchDist = 0;
let lastTouchCenter = null;
vp.addEventListener('touchstart', (e) => {
const state = self._getState(id);
const state = _getState(id);
if (!state || !state.ready) return;
if (e.touches.length === 1) {
state.dragging = true;
@@ -418,28 +260,21 @@ Extensions.register({
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
lastTouchCenter = {
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
y: (e.touches[0].clientY + e.touches[1].clientY) / 2,
};
}
}, { passive: true });
vp.addEventListener('touchmove', (e) => {
const state = self._getState(id);
const state = _getState(id);
if (!state || !state.ready) return;
if (e.touches.length === 1 && state.dragging) {
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
if (!svg) return;
const svgRect = svg.getBoundingClientRect();
if (svgRect.width === 0) return;
const pxToVb = state.vbW / svgRect.width;
const dx = (e.touches[0].clientX - state.startX) * pxToVb;
const dy = (e.touches[0].clientY - state.startY) * pxToVb;
state.vbX = state.startVbX - dx;
state.vbY = state.startVbY - dy;
self._applyViewBox(id);
state.vbX = state.startVbX - (e.touches[0].clientX - state.startX) * pxToVb;
state.vbY = state.startVbY - (e.touches[0].clientY - state.startY) * pxToVb;
_applyViewBox(id);
e.preventDefault();
} else if (e.touches.length === 2) {
const dist = Math.hypot(
@@ -452,46 +287,42 @@ Extensions.register({
};
if (lastTouchDist > 0) {
const factor = (dist - lastTouchDist) / lastTouchDist;
self._zoomAt(id, factor, center.x, center.y);
_zoomAt(id, factor, center.x, center.y);
}
lastTouchDist = dist;
lastTouchCenter = center;
e.preventDefault();
}
}, { passive: false });
vp.addEventListener('touchend', () => {
const state = self._getState(id);
const state = _getState(id);
if (state) state.dragging = false;
lastTouchDist = 0;
lastTouchCenter = null;
}, { passive: true });
});
},
}
// ── Export ───────────────────────────────
_exportSVG(id) {
function _exportSVG(id) {
const diagram = document.querySelector(`[data-diagram="${id}"]`);
const svg = diagram?.querySelector('svg');
if (!svg) return;
const state = this._getState(id);
const state = _getState(id);
const clone = svg.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
if (state?.ready) {
clone.setAttribute('viewBox', `${state.natX} ${state.natY} ${state.natW} ${state.natH}`);
}
const blob = new Blob([clone.outerHTML], { type: 'image/svg+xml' });
this._download(blob, `diagram-${id}.svg`);
},
_download(blob, `diagram-${id}.svg`);
}
_exportPNG(id) {
function _exportPNG(id) {
const diagram = document.querySelector(`[data-diagram="${id}"]`);
const svg = diagram?.querySelector('svg');
if (!svg) return;
const state = this._getState(id);
const state = _getState(id);
const clone = svg.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
if (state?.ready) {
@@ -503,7 +334,6 @@ Extensions.register({
const svgData = new XMLSerializer().serializeToString(clone);
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
const self = this;
const img = new Image();
img.onload = () => {
@@ -515,9 +345,8 @@ Extensions.register({
ctx.scale(scale, scale);
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
canvas.toBlob((blob) => {
if (blob) self._download(blob, `diagram-${id}.png`);
if (blob) _download(blob, `diagram-${id}.png`);
}, 'image/png');
};
img.onerror = () => {
@@ -525,9 +354,9 @@ Extensions.register({
console.error('[Mermaid] PNG export failed');
};
img.src = url;
},
}
_download(blob, filename) {
function _download(blob, filename) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
@@ -535,25 +364,24 @@ Extensions.register({
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
},
}
_copySource(id) {
function _copySource(id) {
const code = document.querySelector(`[data-source="${id}"]`);
if (!code) return;
navigator.clipboard.writeText(code.textContent).then(() => {
this._ctx.ui.toast('Source copied', 'success');
if (window.sw?.toast) sw.toast('Source copied', 'success');
});
},
}
// ── Diagram Rendering ───────────────────
async _renderDiagram(el) {
async function _renderDiagram(el) {
const code = decodeURIComponent(el.dataset.mermaidSrc);
const id = el.dataset.diagram;
try {
await this._loadMermaid();
await _loadMermaid();
const svgId = 'mmd-svg-' + Math.random().toString(36).slice(2, 9);
const { svg } = await mermaid.render(svgId, code);
el.innerHTML = svg;
@@ -566,28 +394,27 @@ Extensions.register({
svgEl.style.maxWidth = 'none';
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
}
this._initState(id);
_initState(id);
} catch (e) {
el.innerHTML = `
<div class="mermaid-error">
<strong>Diagram error:</strong> ${this._escapeHtml(e.message || String(e))}
<strong>Diagram error:</strong> ${_escapeHtml(e.message || String(e))}
</div>
`;
el.dataset.rendered = 'error';
}
},
}
// ── Mermaid Library Loading ──────────────
_loadMermaid() {
if (this._mermaidReady) return Promise.resolve();
if (this._mermaidLoading) return this._mermaidLoading;
function _loadMermaid() {
if (_mermaidReady) return Promise.resolve();
if (_mermaidLoading) return _mermaidLoading;
this._mermaidLoading = new Promise((resolve, reject) => {
_mermaidLoading = new Promise((resolve, reject) => {
if (typeof mermaid !== 'undefined') {
this._initMermaid();
this._mermaidReady = true;
_initMermaid();
_mermaidReady = true;
resolve();
return;
}
@@ -599,8 +426,8 @@ Extensions.register({
const script = document.createElement('script');
script.src = localSrc;
script.onload = () => {
this._initMermaid();
this._mermaidReady = true;
_initMermaid();
_mermaidReady = true;
resolve();
};
script.onerror = () => {
@@ -608,8 +435,8 @@ Extensions.register({
const cdn = document.createElement('script');
cdn.src = cdnSrc;
cdn.onload = () => {
this._initMermaid();
this._mermaidReady = true;
_initMermaid();
_mermaidReady = true;
resolve();
};
cdn.onerror = () => {
@@ -620,25 +447,23 @@ Extensions.register({
};
document.head.appendChild(script);
});
return _mermaidLoading;
}
return this._mermaidLoading;
},
_initMermaid() {
function _initMermaid() {
if (typeof mermaid === 'undefined') return;
mermaid.initialize({
startOnLoad: false,
theme: this._ctx.ui.isDark() ? 'dark' : 'default',
theme: _isDark() ? 'dark' : 'default',
securityLevel: 'strict',
fontFamily: 'inherit',
logLevel: 'error',
});
},
}
// ── Styles ──────────────────────────────
_injectStyles() {
function _injectStyles() {
if (document.getElementById('ext-style-mermaid-renderer')) return;
const style = document.createElement('style');
style.id = 'ext-style-mermaid-renderer';
@@ -647,8 +472,6 @@ Extensions.register({
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; overflow: hidden; margin: 12px 0;
}
/* ── Fullscreen mode ── */
.mermaid-block.mermaid-fullscreen {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
z-index: 10000; border-radius: 0; margin: 0;
@@ -656,15 +479,11 @@ Extensions.register({
background: var(--bg-1, var(--bg-2, #1a1a2e));
}
.mermaid-block.mermaid-fullscreen .mermaid-viewport {
background: var(--bg-2, #1a1a2e);
flex: 1; max-height: none;
background: var(--bg-2, #1a1a2e); flex: 1; max-height: none;
}
.mermaid-block.mermaid-fullscreen .mermaid-toolbar {
border-bottom: 1px solid var(--border);
padding: 6px 14px;
border-bottom: 1px solid var(--border); padding: 6px 14px;
}
/* Fullscreen close button — always visible, critical for mobile */
.mmd-fullscreen-close {
position: absolute; top: 12px; right: 12px; z-index: 10001;
width: 40px; height: 40px; border-radius: 50%;
@@ -676,11 +495,8 @@ Extensions.register({
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.mmd-fullscreen-close:hover {
background: var(--bg-hover, rgba(255,255,255,0.15));
transform: scale(1.1);
background: var(--bg-hover, rgba(255,255,255,0.15)); transform: scale(1.1);
}
/* Toolbar */
.mermaid-toolbar {
display: flex; align-items: center; gap: 6px;
padding: 4px 10px; border-bottom: 1px solid var(--border);
@@ -699,24 +515,14 @@ Extensions.register({
}
.mmd-btn:hover { color: var(--text-1); border-color: var(--text-3); background: var(--bg-3); }
.mmd-sep { width: 1px; height: 16px; background: var(--border); margin: 0 4px; }
/* Viewport */
.mermaid-viewport {
overflow: hidden; position: relative;
min-height: 80px; max-height: 600px;
cursor: grab; user-select: none;
}
.mermaid-viewport.mmd-grabbing { cursor: grabbing; }
/* Diagram wrapper */
.mermaid-diagram {
width: 100%; height: 100%; min-height: 60px;
}
.mermaid-diagram svg {
display: block; width: 100%; height: 100%;
}
/* Loading / Error */
.mermaid-diagram { width: 100%; height: 100%; min-height: 60px; }
.mermaid-diagram svg { display: block; width: 100%; height: 100%; }
.mermaid-loading {
color: var(--text-3); font-size: 13px; padding: 20px;
display: flex; align-items: center; justify-content: center; gap: 8px;
@@ -732,8 +538,6 @@ Extensions.register({
padding: 12px 16px; border-radius: 4px; font-size: 13px;
font-family: var(--mono); white-space: pre-wrap;
}
/* Source panel */
.mermaid-source { border-top: 1px solid var(--border); font-size: 12px; }
.mermaid-source summary {
padding: 6px 12px; cursor: pointer; color: var(--text-3);
@@ -746,34 +550,91 @@ Extensions.register({
margin: 0; border-radius: 0; border: none;
max-height: 200px; overflow: auto;
}
/* Hide source panel in fullscreen */
.mermaid-block.mermaid-fullscreen .mermaid-source { display: none; }
/* ── Mobile adjustments ── */
@media (max-width: 768px) {
.mermaid-toolbar { padding: 6px 10px; gap: 4px; }
.mmd-btn { padding: 4px 10px; font-size: 12px; }
.mmd-sep { margin: 0 2px; }
.mmd-fullscreen-close { width: 48px; height: 48px; font-size: 24px; top: 16px; right: 16px; }
}
`;
}`;
document.head.appendChild(style);
},
_escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
destroy() {
document.getElementById('ext-style-mermaid-renderer')?.remove();
document.querySelectorAll('.mermaid-fullscreen').forEach(el => {
el.classList.remove('mermaid-fullscreen');
const closeBtn = el.querySelector('.mmd-fullscreen-close');
if (closeBtn) closeBtn.remove();
});
document.body.style.overflow = '';
}
});
// ── Registration ────────────────────────
function register() {
if (!window.sw?.renderers) return;
_injectStyles();
// Block renderer: match ```mermaid
sw.renderers.register('mermaid', {
type: 'block',
pattern: 'mermaid',
priority: 10,
render(lang, code, container) {
const id = 'mmd-' + Math.random().toString(36).slice(2, 9);
container.innerHTML = `
<div class="mermaid-block" data-mermaid-id="${id}">
<div class="mermaid-toolbar">
<span class="mermaid-title">\u{1f4ca} Diagram</span>
<span class="mermaid-zoom-label" data-zoom-label="${id}">100%</span>
<div class="mermaid-toolbar-btns">
<button class="mmd-btn" data-action="zoom-in" data-target="${id}" title="Zoom in">+</button>
<button class="mmd-btn" data-action="zoom-out" data-target="${id}" title="Zoom out">\u2212</button>
<button class="mmd-btn" data-action="zoom-fit" data-target="${id}" title="Fit to view">\u22a1</button>
<button class="mmd-btn" data-action="zoom-reset" data-target="${id}" title="Reset zoom">1:1</button>
<span class="mmd-sep"></span>
<button class="mmd-btn" data-action="expand" data-target="${id}" title="Fullscreen">\u26f6</button>
<span class="mmd-sep"></span>
<button class="mmd-btn" data-action="export-svg" data-target="${id}" title="Download SVG">SVG</button>
<button class="mmd-btn" data-action="export-png" data-target="${id}" title="Download PNG">PNG</button>
</div>
</div>
<div class="mermaid-viewport" data-viewport="${id}">
<div class="mermaid-diagram" data-mermaid-src="${encodeURIComponent(code.trim())}" data-diagram="${id}">
<div class="mermaid-loading">
<span class="mermaid-spinner"></span> Rendering diagram\u2026
</div>
</div>
</div>
<details class="mermaid-source">
<summary>
<span>\u{1f4cb} View source</span>
<button class="mmd-btn mmd-copy-src" data-action="copy-src" data-target="${id}" title="Copy source" onclick="event.stopPropagation()">Copy</button>
</summary>
<pre><code class="language-mermaid" data-source="${id}">${_escapeHtml(code.trim())}</code></pre>
</details>
</div>
`;
}
});
// Post renderer: render diagrams + wire interactivity
sw.renderers.register('mermaid-post', {
type: 'post',
priority: 10,
render(container) {
const diagrams = container.querySelectorAll('.mermaid-diagram[data-mermaid-src]');
if (diagrams.length === 0) return;
diagrams.forEach(el => {
if (el.dataset.rendered) return;
el.dataset.rendered = 'pending';
_renderDiagram(el);
});
_wireToolbar(container);
}
});
_loadMermaid();
}
// Boot: register immediately if SDK ready, otherwise listen
if (window.sw?._sdk) {
register();
} else {
document.addEventListener('sw:ready', register, { once: true });
}
})();

View File

@@ -69,100 +69,13 @@
}
// ═══════════════════════════════════════════
// Markdown renderer (inline, no deps)
// Markdown rendering via sw.markdown
// ═══════════════════════════════════════════
// Preload marked + DOMPurify so renderSync works in useMemo
sw.markdown.preload();
function renderMarkdown(src) {
if (!src) return '';
var lines = src.split('\n');
var out = [];
var inCode = false;
var inList = false;
var listTag = '';
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
// fenced code blocks
if (line.trim().startsWith('```')) {
if (inCode) {
out.push('</code></pre>');
inCode = false;
} else {
if (inList) { out.push('</' + listTag + '>'); inList = false; }
out.push('<pre><code>');
inCode = true;
}
continue;
}
if (inCode) {
out.push(escHtml(line) + '\n');
continue;
}
// blank line
if (!line.trim()) {
if (inList) { out.push('</' + listTag + '>'); inList = false; }
continue;
}
// headings
if (line.startsWith('### ')) { closeList(); out.push('<h3>' + inline(line.slice(4)) + '</h3>'); continue; }
if (line.startsWith('## ')) { closeList(); out.push('<h2>' + inline(line.slice(3)) + '</h2>'); continue; }
if (line.startsWith('# ')) { closeList(); out.push('<h1>' + inline(line.slice(2)) + '</h1>'); continue; }
// hr
if (/^[-*_]{3,}\s*$/.test(line)) { closeList(); out.push('<hr>'); continue; }
// blockquote
if (line.startsWith('> ')) { closeList(); out.push('<blockquote><p>' + inline(line.slice(2)) + '</p></blockquote>'); continue; }
// unordered list
if (/^[\-*+]\s/.test(line)) {
if (!inList || listTag !== 'ul') { closeList(); out.push('<ul>'); inList = true; listTag = 'ul'; }
out.push('<li>' + inline(line.replace(/^[\-*+]\s/, '')) + '</li>');
continue;
}
// ordered list
if (/^\d+\.\s/.test(line)) {
if (!inList || listTag !== 'ol') { closeList(); out.push('<ol>'); inList = true; listTag = 'ol'; }
out.push('<li>' + inline(line.replace(/^\d+\.\s/, '')) + '</li>');
continue;
}
// paragraph
closeList();
out.push('<p>' + inline(line) + '</p>');
}
if (inCode) out.push('</code></pre>');
if (inList) out.push('</' + listTag + '>');
return out.join('\n');
function closeList() { if (inList) { out.push('</' + listTag + '>'); inList = false; } }
}
function escHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function inline(s) {
s = escHtml(s);
// inline code
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
// bold
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
s = s.replace(/__(.+?)__/g, '<strong>$1</strong>');
// italic
s = s.replace(/\*(.+?)\*/g, '<em>$1</em>');
s = s.replace(/_(.+?)_/g, '<em>$1</em>');
// links
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// wikilinks: [[Note Title]] → clickable internal link
s = s.replace(/\[\[([^\]]+)\]\]/g, function(match, linkText) {
return '<a class="wikilink" data-wikilink="' + linkText.trim() + '" href="#">' + linkText.trim() + '</a>';
});
return s;
function _escAttr(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
@@ -854,13 +767,13 @@
}, [note ? note.id : null, saveCount]);
var previewHtml = useMemo(function() {
var h = renderMarkdown(body);
var h = sw.markdown.renderSync(body, { sanitize: true });
// mark unresolved wikilinks
for (var i = 0; i < outgoingLinks.length; i++) {
var link = outgoingLinks[i];
if (!link.target_id) {
var search = 'class="wikilink" data-wikilink="' + escHtml(link.link_text) + '"';
var replace = 'class="wikilink wikilink--unresolved" data-wikilink="' + escHtml(link.link_text) + '"';
var search = 'class="wikilink" data-wikilink="' + _escAttr(link.link_text) + '"';
var replace = 'class="wikilink wikilink--unresolved" data-wikilink="' + _escAttr(link.link_text) + '"';
h = h.split(search).join(replace);
}
}
@@ -884,6 +797,13 @@
return function() { el.removeEventListener('click', handleClick); };
}, [viewMode, onNavigateToTitle]);
// ── Run post-renderers (mermaid, katex, etc.) on preview ──
useEffect(function() {
var el = previewRef.current;
if (!el || viewMode === 'edit') return;
sw.renderers.runPostRenderers(el);
}, [previewHtml, viewMode]);
// ── Synced scroll in split mode ──
useEffect(function() {
if (viewMode !== 'split' || !cmEditorRef.current) return;

View File

@@ -82,7 +82,7 @@
await T.test('admin', 'surfaces', 'sw.api.admin.surfaces.list()', {
sdk: function () { return sw.api.admin.surfaces.list(); },
raw: { method: 'GET', path: '/admin/surfaces' },
raw: { method: 'GET', path: '/admin/packages' },
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
});

View File

@@ -55,7 +55,6 @@ func (h *PackageHandler) SetRunner(r *sandbox.Runner) {
// ListPackages returns all registered packages.
// GET /api/v1/admin/packages
// GET /api/v1/admin/surfaces (alias)
func (h *PackageHandler) ListPackages(c *gin.Context) {
typeFilter := c.Query("type")
@@ -78,7 +77,6 @@ func (h *PackageHandler) ListPackages(c *gin.Context) {
// GetPackage returns a single package by ID.
// GET /api/v1/admin/packages/:id
// GET /api/v1/admin/surfaces/:id (alias)
func (h *PackageHandler) GetPackage(c *gin.Context) {
id := c.Param("id")
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
@@ -91,7 +89,6 @@ func (h *PackageHandler) GetPackage(c *gin.Context) {
// EnablePackage enables a package.
// PUT /api/v1/admin/packages/:id/enable
// PUT /api/v1/admin/surfaces/:id/enable (alias)
func (h *PackageHandler) EnablePackage(c *gin.Context) {
id := c.Param("id")
@@ -116,7 +113,6 @@ func (h *PackageHandler) EnablePackage(c *gin.Context) {
// DisablePackage disables a package. Admin cannot be disabled.
// PUT /api/v1/admin/packages/:id/disable
// PUT /api/v1/admin/surfaces/:id/disable (alias)
func (h *PackageHandler) DisablePackage(c *gin.Context) {
id := c.Param("id")
@@ -134,7 +130,6 @@ func (h *PackageHandler) DisablePackage(c *gin.Context) {
// DeletePackage uninstalls a package. Core packages cannot be deleted.
// DELETE /api/v1/admin/packages/:id
// DELETE /api/v1/admin/surfaces/:id (alias)
func (h *PackageHandler) DeletePackage(c *gin.Context) {
id := c.Param("id")
@@ -182,7 +177,6 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) {
// InstallPackage uploads and installs a .pkg/.surface archive.
// POST /api/v1/admin/packages/install
// POST /api/v1/admin/surfaces/install (alias)
//
// Also supports pre-downloaded files via gin context:
// c.Set("_registry_file", "/path/to/file.pkg") — skip form upload

View File

@@ -830,14 +830,6 @@ func main() {
admin.DELETE("/backups/:name", backupH.DeleteBackup)
admin.POST("/restore", backupH.RestoreBackup)
// Surface aliases (backward compat — same handlers)
admin.GET("/surfaces", pkgAdm.ListPackages)
admin.GET("/surfaces/:id", pkgAdm.GetPackage)
admin.POST("/surfaces/install", pkgAdm.InstallPackage)
admin.PUT("/surfaces/:id/enable", pkgAdm.EnablePackage)
admin.PUT("/surfaces/:id/disable", pkgAdm.DisablePackage)
admin.DELETE("/surfaces/:id", pkgAdm.DeletePackage)
}
}

View File

@@ -104,6 +104,8 @@ type PageData struct {
ExtensionSurfaces []ExtensionNavItem `json:"-"`
BrowserExtensions []string `json:"-"` // IDs of enabled browser-tier extensions (for script injection)
InstanceName string // branding: instance display name
LogoURL string // branding: custom logo URL
Tagline string // branding: tagline under instance name
@@ -178,6 +180,29 @@ func (e *Engine) extensionNavItems() []ExtensionNavItem {
return items
}
// browserExtensionIDs returns IDs of enabled browser-tier extensions.
// These extensions have JS scripts that should be injected into every page
// so they can register renderers with the SDK.
func (e *Engine) browserExtensionIDs() []string {
if e.stores.Packages == nil {
return nil
}
ctx := context.Background()
pkgs, err := e.stores.Packages.List(ctx)
if err != nil {
log.Printf("[pages] Failed to load browser extensions: %v", err)
return nil
}
var ids []string
for _, pkg := range pkgs {
if pkg.Enabled && pkg.Type == "extension" && pkg.Tier == "browser" {
ids = append(ids, pkg.ID)
}
}
return ids
}
// UserContext is the authenticated user's info available to templates.
type UserContext struct {
ID string `json:"id"`
@@ -370,6 +395,7 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
Manifest: e.GetSurface(surfaceID),
EnabledSurfaces: e.EnabledSurfaceIDs(),
ExtensionSurfaces: e.extensionNavItems(),
BrowserExtensions: e.browserExtensionIDs(),
})
}
}
@@ -431,6 +457,7 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
Manifest: manifest,
EnabledSurfaces: e.EnabledSurfaceIDs(),
ExtensionSurfaces: e.extensionNavItems(),
BrowserExtensions: e.browserExtensionIDs(),
})
}
}

View File

@@ -21,7 +21,7 @@
<link rel="stylesheet" href="{{.BasePath}}/css/extension-surface.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/sw-shell.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/sw-debug.css?v={{.Version}}">
{{/* gutted: css-chat, css-notes, css-projects templates removed */}}
{{/* css-chat, css-notes, css-projects templates removed — surfaces provide their own styles */}}
{{/* v0.27.0: Extension surface CSS — loaded from /surfaces/{id}/css/main.css */}}
{{if and .Manifest (eq .Manifest.Source "extension")}}
<link rel="stylesheet" href="{{.BasePath}}/surfaces/{{.Surface}}/css/main.css?v={{.Version}}">
@@ -117,9 +117,8 @@
{{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}}
</script>
{{/* v0.37.14 Scorched Earth IV: sb.js, events.js, switchboard-sdk.js,
workflow-surfaces.js removed. All surfaces use Preact SDK boot().
Survivors: debug.js, repl.js (standalone, no old-layer deps). */}}
{{/* All surfaces use Preact SDK boot(). Legacy script includes removed.
Standalone utils: debug.js, repl.js (no SDK dependencies). */}}
{{if eq .Surface "admin"}}{{template "scripts-admin" .}}{{end}}
{{if eq .Surface "team-admin"}}{{template "scripts-team-admin" .}}{{end}}
@@ -142,6 +141,12 @@
</script>
{{end}}
{{/* v0.6.5: Browser-tier extension scripts (renderers, etc.)
Loaded after surface scripts so sw:ready fires first. */}}
{{range .BrowserExtensions}}
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/surfaces/{{.}}/js/script.js?v={{$.Version}}"></script>
{{end}}
{{/* v0.37.13: Legacy UserMenu hydration removed — all surfaces use Preact UserMenu. */}}
{{/* ── Debug Modal (Preact, v0.37.18) ───── */}}

View File

@@ -0,0 +1,136 @@
const { describe, it } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const SRC = path.join(__dirname, '..');
/**
* Load the renderers module into a VM context and return the factory.
*/
function loadRenderers() {
const source = fs.readFileSync(path.join(SRC, 'sw/sdk/renderers.js'), 'utf-8');
// Strip ESM syntax for VM execution
const cjs = source.replace(/^export /gm, '');
const sandbox = { console, module: { exports: {} } };
vm.createContext(sandbox);
vm.runInContext(cjs, sandbox);
return sandbox.createRenderers;
}
describe('sw.renderers', () => {
const createRenderers = loadRenderers();
it('register() adds a block renderer', () => {
const events = [];
const r = createRenderers((name, data, opts) => events.push({ name, data }));
r.register('test-block', { type: 'block', pattern: 'mermaid', render() {} });
const info = r.list();
assert.strictEqual(info.block.length, 1);
assert.strictEqual(info.block[0].name, 'test-block');
assert.strictEqual(events.length, 1);
assert.strictEqual(events[0].data.action, 'register');
});
it('register() adds a post renderer', () => {
const r = createRenderers(() => {});
r.register('test-post', { type: 'post', render() {} });
const info = r.list();
assert.strictEqual(info.post.length, 1);
assert.strictEqual(info.post[0].name, 'test-post');
});
it('register() replaces duplicate by name', () => {
const r = createRenderers(() => {});
r.register('dup', { type: 'block', pattern: 'x', render() {} });
r.register('dup', { type: 'block', pattern: 'y', render() {} });
const info = r.list();
assert.strictEqual(info.block.length, 1);
assert.strictEqual(info.block[0].pattern, 'y');
});
it('unregister() removes a renderer', () => {
const r = createRenderers(() => {});
r.register('rm-me', { type: 'block', pattern: 'x', render() {} });
r.unregister('rm-me');
assert.strictEqual(r.list().block.length, 0);
});
it('register() returns an unregister function', () => {
const r = createRenderers(() => {});
const unreg = r.register('auto-unreg', { type: 'post', render() {} });
assert.strictEqual(r.list().post.length, 1);
unreg();
assert.strictEqual(r.list().post.length, 0);
});
it('runBlockRenderers() matches by pattern', () => {
const r = createRenderers(() => {});
let called = false;
r.register('m', { type: 'block', pattern: 'mermaid', render() { called = true; } });
const result = r.runBlockRenderers('mermaid', 'code', {});
assert.ok(result);
assert.ok(called);
});
it('runBlockRenderers() matches by match function', () => {
const r = createRenderers(() => {});
let called = false;
r.register('k', {
type: 'block',
match(lang) { return lang === 'latex' || lang === 'math'; },
render() { called = true; },
});
assert.ok(r.runBlockRenderers('latex', 'code', {}));
assert.ok(called);
});
it('runBlockRenderers() returns false when no match', () => {
const r = createRenderers(() => {});
r.register('m', { type: 'block', pattern: 'mermaid', render() {} });
assert.strictEqual(r.runBlockRenderers('javascript', 'code', {}), false);
});
it('runBlockRenderers() case-insensitive on lang', () => {
const r = createRenderers(() => {});
let called = false;
r.register('m', { type: 'block', pattern: 'mermaid', render() { called = true; } });
assert.ok(r.runBlockRenderers('Mermaid', 'code', {}));
assert.ok(called);
});
it('runPostRenderers() calls all post renderers', () => {
const r = createRenderers(() => {});
const order = [];
r.register('a', { type: 'post', priority: 20, render() { order.push('a'); } });
r.register('b', { type: 'post', priority: 10, render() { order.push('b'); } });
r.runPostRenderers({});
assert.deepStrictEqual(order, ['b', 'a']); // priority 10 first
});
it('block renderers respect priority (first match wins)', () => {
const r = createRenderers(() => {});
let winner = '';
r.register('low', { type: 'block', pattern: 'test', priority: 50, render() { winner = 'low'; } });
r.register('high', { type: 'block', pattern: 'test', priority: 10, render() { winner = 'high'; } });
r.runBlockRenderers('test', '', {});
assert.strictEqual(winner, 'high');
});
it('runBlockRenderers() catches render errors gracefully', () => {
const r = createRenderers(() => {});
r.register('bad', { type: 'block', pattern: 'err', render() { throw new Error('boom'); } });
const result = r.runBlockRenderers('err', 'code', {});
assert.strictEqual(result, false); // error → returns false
});
it('runPostRenderers() catches errors and continues', () => {
const r = createRenderers(() => {});
const order = [];
r.register('bad', { type: 'post', priority: 10, render() { throw new Error('boom'); } });
r.register('good', { type: 'post', priority: 20, render() { order.push('good'); } });
r.runPostRenderers({});
assert.deepStrictEqual(order, ['good']); // continues after error
});
});

View File

@@ -268,12 +268,12 @@ export function createDomains(restClient) {
},
surfaces: {
list: () => rc.get('/api/v1/admin/surfaces'),
get: (id) => rc.get(`/api/v1/admin/surfaces/${id}`),
install: (file) => rc.upload('/api/v1/admin/surfaces/install', file),
enable: (id) => rc.put(`/api/v1/admin/surfaces/${id}/enable`, {}),
disable: (id) => rc.put(`/api/v1/admin/surfaces/${id}/disable`, {}),
del: (id) => rc.del(`/api/v1/admin/surfaces/${id}`),
list: () => rc.get('/api/v1/admin/packages'),
get: (id) => rc.get(`/api/v1/admin/packages/${id}`),
install: (file) => rc.upload('/api/v1/admin/packages/install', file),
enable: (id) => rc.put(`/api/v1/admin/packages/${id}/enable`, {}),
disable: (id) => rc.put(`/api/v1/admin/packages/${id}/disable`, {}),
del: (id) => rc.del(`/api/v1/admin/packages/${id}`),
},
backup: {

View File

@@ -22,6 +22,8 @@ import { createStorage } from './storage.js';
import { createSlots } from './slots.js';
import { createActions } from './actions.js';
import { createRealtime } from './realtime.js';
import { createRenderers } from './renderers.js';
import { createMarkdown } from './markdown.js';
import { confirm } from '../primitives/confirm.js';
import { prompt } from '../primitives/prompt.js';
@@ -72,6 +74,8 @@ export async function boot() {
const slots = createSlots(events.emit.bind(events));
const actions = createActions(events.emit.bind(events));
const realtime = createRealtime(events);
const renderers = createRenderers(events.emit.bind(events));
const markdown = createMarkdown(renderers);
// 7. Assemble sw object
const sw = Object.create(null);
@@ -107,6 +111,12 @@ export async function boot() {
// Realtime — room-scoped pub/sub over WebSocket
sw.realtime = realtime;
// Renderers — kernel-level block/post renderer registry
sw.renderers = renderers;
// Markdown — unified renderer (marked + DOMPurify + sw.renderers pipeline)
sw.markdown = markdown;
// Shell helpers — imperative confirm/prompt backed by primitives
sw.confirm = confirm;
sw.prompt = prompt;
@@ -167,7 +177,7 @@ export async function boot() {
};
// Marker for idempotency
sw._sdk = '0.5.1';
sw._sdk = '0.6.5';
// 8. Expose globally
window.sw = sw;

252
src/js/sw/sdk/markdown.js Normal file
View File

@@ -0,0 +1,252 @@
// ==========================================
// Switchboard Core — SDK: Markdown Renderer
// ==========================================
// Unified markdown rendering via `marked` (v16, vendored).
// Delegates fenced code blocks to sw.renderers and
// sanitizes output with DOMPurify.
//
// Factory: createMarkdown(renderers)
// ==========================================
/**
* Create the markdown module.
*
* @param {object} renderers — sw.renderers instance
* @returns {object} markdown
*/
export function createMarkdown(renderers) {
let _marked = null;
let _purify = null;
let _markedLoading = null;
let _purifyLoading = null;
const BASE = () => window.__BASE__ || '';
// ── Lazy loaders ──────────────────────────
function _loadMarked() {
if (_marked) return Promise.resolve(_marked);
if (_markedLoading) return _markedLoading;
_markedLoading = new Promise((resolve, reject) => {
if (window.marked) {
_marked = window.marked;
_configure();
resolve(_marked);
return;
}
const script = document.createElement('script');
script.src = `${BASE()}/vendor/marked.min.js`;
script.onload = () => {
_marked = window.marked;
_configure();
resolve(_marked);
};
script.onerror = () => {
reject(new Error('[sw.markdown] Failed to load marked'));
};
document.head.appendChild(script);
});
return _markedLoading;
}
function _loadPurify() {
if (_purify) return Promise.resolve(_purify);
if (_purifyLoading) return _purifyLoading;
_purifyLoading = new Promise((resolve, reject) => {
if (window.DOMPurify) {
_purify = window.DOMPurify;
resolve(_purify);
return;
}
const script = document.createElement('script');
script.src = `${BASE()}/vendor/purify.min.js`;
script.onload = () => {
_purify = window.DOMPurify;
resolve(_purify);
};
script.onerror = () => {
reject(new Error('[sw.markdown] Failed to load DOMPurify'));
};
document.head.appendChild(script);
});
return _purifyLoading;
}
// ── Marked configuration ──────────────────
function _configure() {
if (!_marked) return;
_marked.use({
// Wikilink tokenizer extension: [[Page Name]] and [[Page Name|Display]]
extensions: [{
name: 'wikilink',
level: 'inline',
start(src) {
return src.indexOf('[[');
},
tokenizer(src) {
const match = src.match(/^\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/);
if (match) {
return {
type: 'wikilink',
raw: match[0],
target: match[1].trim(),
display: (match[2] || match[1]).trim(),
};
}
},
renderer(token) {
const target = _escapeAttr(token.target);
const display = _escapeHtml(token.display);
return `<a class="wikilink" data-wikilink="${target}" href="javascript:void(0)">${display}</a>`;
},
}],
// Custom code block renderer — delegates to sw.renderers
renderer: {
code({ text, lang }) {
if (lang && renderers) {
const container = document.createElement('div');
if (renderers.runBlockRenderers(lang, text, container)) {
return container.innerHTML;
}
}
// Default: standard <pre><code>
const langClass = lang ? ` class="language-${_escapeAttr(lang)}"` : '';
return `<pre><code${langClass}>${_escapeHtml(text)}</code></pre>\n`;
},
},
});
}
// ── DOMPurify config ──────────────────────
// Allow SVG elements (for mermaid) + data attributes (for all renderers)
const PURIFY_CONFIG = {
ADD_TAGS: [
'svg', 'g', 'path', 'rect', 'circle', 'ellipse', 'line',
'polyline', 'polygon', 'text', 'tspan', 'defs', 'marker',
'foreignObject', 'use', 'clipPath', 'mask', 'pattern',
'linearGradient', 'radialGradient', 'stop', 'title', 'desc',
],
ADD_ATTR: [
'viewBox', 'd', 'fill', 'stroke', 'stroke-width', 'stroke-dasharray',
'xmlns', 'xmlns:xlink', 'transform', 'cx', 'cy', 'r', 'rx', 'ry',
'x', 'y', 'x1', 'y1', 'x2', 'y2', 'width', 'height',
'preserveAspectRatio', 'points', 'font-size', 'font-family',
'text-anchor', 'dominant-baseline', 'dy', 'dx',
'marker-start', 'marker-mid', 'marker-end',
'refX', 'refY', 'markerWidth', 'markerHeight', 'orient',
'gradientTransform', 'gradientUnits', 'offset', 'stop-color', 'stop-opacity',
'clip-path', 'opacity', 'fill-opacity', 'stroke-opacity',
],
ALLOW_DATA_ATTR: true,
};
// ── Helpers ───────────────────────────────
function _escapeHtml(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _escapeAttr(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// ── Public API ────────────────────────────
return {
/**
* Render markdown source to HTML string.
* Loads marked lazily on first call.
*
* @param {string} src — markdown source
* @param {object} [opts]
* @param {boolean} [opts.sanitize=true] — run DOMPurify on output
* @returns {Promise<string>} HTML
*/
async render(src, opts = {}) {
const sanitize = opts.sanitize !== false;
await _loadMarked();
let html = _marked.parse(src || '');
if (sanitize) {
await _loadPurify();
html = _purify.sanitize(html, PURIFY_CONFIG);
}
return html;
},
/**
* Synchronous render — only works after marked has been loaded.
* Falls back to escaping if marked isn't ready.
*
* @param {string} src
* @param {object} [opts]
* @param {boolean} [opts.sanitize=true]
* @returns {string} HTML
*/
renderSync(src, opts = {}) {
const sanitize = opts.sanitize !== false;
if (!_marked) {
return _escapeHtml(src || '');
}
let html = _marked.parse(src || '');
if (sanitize && _purify) {
html = _purify.sanitize(html, PURIFY_CONFIG);
}
return html;
},
/**
* Render markdown into a DOM element, then run post renderers.
*
* @param {HTMLElement} container
* @param {string} src
* @param {object} [opts]
* @param {boolean} [opts.sanitize=true]
*/
async renderToElement(container, src, opts = {}) {
const html = await this.render(src, opts);
container.innerHTML = html;
if (renderers) {
renderers.runPostRenderers(container);
}
},
/**
* Preload both marked and DOMPurify.
* Call early to avoid latency on first render.
*/
async preload() {
await Promise.all([_loadMarked(), _loadPurify()]);
},
/** True if marked is loaded and ready for sync use. */
get ready() {
return !!_marked;
},
};
}

136
src/js/sw/sdk/renderers.js Normal file
View File

@@ -0,0 +1,136 @@
// ==========================================
// Switchboard Core — SDK: Renderer Registry
// ==========================================
// Kernel-level renderer registration. Extensions register
// block renderers (fenced code blocks) and post renderers
// (DOM post-processing) once; all surfaces consume via
// sw.markdown or direct runBlockRenderers/runPostRenderers.
//
// Factory: createRenderers(emitFn)
// ==========================================
/**
* Create the renderer registry.
*
* @param {Function} emitFn — events.emit for change notifications
* @returns {object} renderers
*/
export function createRenderers(emitFn) {
/** @type {Array<{name:string, type:string, pattern?:string, match?:Function, priority:number, render:Function}>} */
const _block = [];
/** @type {Array<{name:string, priority:number, render:Function}>} */
const _post = [];
function _sortByPriority(arr) {
arr.sort((a, b) => a.priority - b.priority);
}
return {
/**
* Register a renderer.
*
* @param {string} name — unique name (e.g. 'mermaid', 'katex-block')
* @param {object} opts
* @param {string} opts.type — 'block' or 'post'
* @param {string} [opts.pattern] — exact lang match (block only)
* @param {Function} [opts.match] — match(lang) → boolean (block only)
* @param {number} [opts.priority=100] — lower runs first
* @param {Function} opts.render — render(lang, code, container) for block,
* render(container) for post
* @returns {Function} unregister
*/
register(name, { type, pattern, match, priority = 100, render }) {
if (type === 'post') {
// Remove existing with same name
const idx = _post.findIndex(r => r.name === name);
if (idx !== -1) _post.splice(idx, 1);
_post.push({ name, priority, render });
_sortByPriority(_post);
emitFn('renderers.changed', { name, type, action: 'register' }, { localOnly: true });
return () => this.unregister(name);
}
// Block renderer
const idx = _block.findIndex(r => r.name === name);
if (idx !== -1) _block.splice(idx, 1);
_block.push({ name, type: 'block', pattern, match, priority, render });
_sortByPriority(_block);
emitFn('renderers.changed', { name, type: 'block', action: 'register' }, { localOnly: true });
return () => this.unregister(name);
},
/**
* Remove a renderer by name.
* @param {string} name
*/
unregister(name) {
let found = false;
const bi = _block.findIndex(r => r.name === name);
if (bi !== -1) { _block.splice(bi, 1); found = true; }
const pi = _post.findIndex(r => r.name === name);
if (pi !== -1) { _post.splice(pi, 1); found = true; }
if (found) {
emitFn('renderers.changed', { name, action: 'unregister' }, { localOnly: true });
}
},
/**
* Run block renderers for a fenced code block.
* First match wins. Returns true if handled.
*
* @param {string} lang — language tag from fenced block
* @param {string} code — raw code content
* @param {HTMLElement} container — DOM element to render into
* @returns {boolean} true if a renderer handled it
*/
runBlockRenderers(lang, code, container) {
const l = (lang || '').toLowerCase();
for (const r of _block) {
const matched = r.pattern
? r.pattern === l
: (r.match ? r.match(l) : false);
if (matched) {
try {
r.render(lang, code, container);
} catch (e) {
console.error(`[sw.renderers] Block renderer "${r.name}" error:`, e);
return false;
}
return true;
}
}
return false;
},
/**
* Run all post renderers on a container.
* @param {HTMLElement} container
*/
runPostRenderers(container) {
for (const r of _post) {
try {
r.render(container);
} catch (e) {
console.error(`[sw.renderers] Post renderer "${r.name}" error:`, e);
}
}
},
/**
* List registered renderers (debug).
* @returns {{ block: Array<{name:string, priority:number}>, post: Array<{name:string, priority:number}> }}
*/
list() {
return {
block: _block.map(r => ({ name: r.name, priority: r.priority, pattern: r.pattern })),
post: _post.map(r => ({ name: r.name, priority: r.priority })),
};
},
};
}

View File

@@ -10,9 +10,12 @@
*
*/
const { html } = window;
const { useState, useEffect, useCallback, useMemo } = hooks;
const { useState, useEffect, useCallback, useMemo, useRef } = hooks;
const { render } = preact;
// Preload marked so renderSync works in useMemo
if (sw?.markdown) sw.markdown.preload();
import { Topbar } from '../../shell/topbar.js';
function DocsSurface() {
@@ -83,6 +86,20 @@ function DocsSurface() {
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ── Rendered HTML via sw.markdown ──
const articleRef = useRef(null);
const renderedHtml = useMemo(() => {
if (!content || !sw?.markdown?.ready) return '';
return sw.markdown.renderSync(content, { sanitize: false });
}, [content]);
// Run post-renderers (mermaid, katex, etc.) after article is painted
useEffect(() => {
const el = articleRef.current;
if (!el || !renderedHtml) return;
if (sw?.renderers) sw.renderers.runPostRenderers(el);
}, [renderedHtml]);
function retryList() {
setListError(false);
sw.api.get('/api/v1/docs').then(r => {
@@ -113,8 +130,8 @@ function DocsSurface() {
${loading ? html`
<div class="docs-loading">Loading\u2026</div>
` : html`
<article class="docs-article"
dangerouslySetInnerHTML=${{ __html: renderMarkdown(content) }}>
<article class="docs-article" ref=${articleRef}
dangerouslySetInnerHTML=${{ __html: renderedHtml }}>
</article>
`}
</main>
@@ -135,170 +152,6 @@ function DocsSurface() {
`;
}
// ── Simple markdown renderer ─────────────────
// Handles: headings, code blocks, inline code, bold, italic, links, lists, paragraphs, tables, hr.
// Not a full CommonMark parser — good enough for documentation.
function renderMarkdown(md) {
if (!md) return '';
let html = '';
const lines = md.split('\n');
let i = 0;
let inCode = false;
let codeLang = '';
let codeLines = [];
let inList = false;
let listType = '';
let inTable = false;
let tableRows = [];
function esc(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function inline(s) {
s = esc(s);
// Links [text](url)
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// Bold **text** or __text__
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
s = s.replace(/__(.+?)__/g, '<strong>$1</strong>');
// Italic *text* or _text_
s = s.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
// Inline code `code`
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
return s;
}
function flushTable() {
if (!inTable || tableRows.length === 0) return;
inTable = false;
html += '<table class="data-table"><thead><tr>';
const headers = tableRows[0];
for (const h of headers) html += `<th>${inline(h.trim())}</th>`;
html += '</tr></thead><tbody>';
for (let r = 2; r < tableRows.length; r++) {
html += '<tr>';
for (const cell of tableRows[r]) html += `<td>${inline(cell.trim())}</td>`;
html += '</tr>';
}
html += '</tbody></table>';
tableRows = [];
}
function flushList() {
if (!inList) return;
inList = false;
html += listType === 'ol' ? '</ol>' : '</ul>';
}
while (i < lines.length) {
const line = lines[i];
// Fenced code blocks
if (line.startsWith('```')) {
if (inCode) {
html += `<pre><code class="language-${esc(codeLang)}">${esc(codeLines.join('\n'))}</code></pre>`;
inCode = false;
codeLines = [];
codeLang = '';
} else {
flushList();
flushTable();
inCode = true;
codeLang = line.slice(3).trim();
}
i++;
continue;
}
if (inCode) {
codeLines.push(line);
i++;
continue;
}
// Table rows
if (line.includes('|') && line.trim().startsWith('|')) {
flushList();
const cells = line.split('|').slice(1, -1);
if (!inTable) inTable = true;
// Skip separator row (---|---)
if (cells.every(c => /^[\s:-]+$/.test(c))) {
tableRows.push(cells); // keep for row counting
} else {
tableRows.push(cells);
}
i++;
continue;
} else {
flushTable();
}
// Headings
const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
if (headingMatch) {
flushList();
const level = headingMatch[1].length;
const text = headingMatch[2];
const id = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
html += `<h${level} id="${id}">${inline(text)}</h${level}>`;
i++;
continue;
}
// Horizontal rule
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
flushList();
html += '<hr>';
i++;
continue;
}
// Unordered list
if (/^\s*[-*+]\s+/.test(line)) {
if (!inList || listType !== 'ul') {
flushList();
inList = true;
listType = 'ul';
html += '<ul>';
}
html += `<li>${inline(line.replace(/^\s*[-*+]\s+/, ''))}</li>`;
i++;
continue;
}
// Ordered list
if (/^\s*\d+\.\s+/.test(line)) {
if (!inList || listType !== 'ol') {
flushList();
inList = true;
listType = 'ol';
html += '<ol>';
}
html += `<li>${inline(line.replace(/^\s*\d+\.\s+/, ''))}</li>`;
i++;
continue;
}
flushList();
// Empty line
if (line.trim() === '') {
i++;
continue;
}
// Paragraph
html += `<p>${inline(line)}</p>`;
i++;
}
flushList();
flushTable();
return html;
}
// ── Styles ──────────────────────────────────
const style = document.createElement('style');
style.textContent = `