Feat v0.6.5 renderer pipeline (#40)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #40.
This commit is contained in:
85
CHANGELOG.md
85
CHANGELOG.md
@@ -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
64
CONTRIBUTING.md
Normal 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.
|
||||
54
ROADMAP.md
54
ROADMAP.md
@@ -1,6 +1,6 @@
|
||||
# Switchboard Core — Roadmap
|
||||
|
||||
## Current: v0.6.4 — Admin Health/Metrics Tab + Cluster Merge
|
||||
## Current: v0.6.5 — Renderer 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,34 @@ 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:819–826` 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.7 — Native mTLS
|
||||
|
||||
Final pass before the hard fork. Security, correctness, and developer experience.
|
||||
End-to-end mutual TLS without a reverse proxy. Targets systemd+podman deployments where every connection (client→server, node→node) is mTLS. Design: `docs/DESIGN-native-mtls.md`.
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| `TLS_MODE` config | ☐ | Three values: `none` (default, plain HTTP) · `server` (TLS, no client cert) · `mtls` (mutual TLS, client cert required). Independent of `AUTH_MODE`. |
|
||||
| TLS server mode | ☐ | Go binary calls `ListenAndServeTLS` directly. `TLS_CERT`, `TLS_KEY`, `TLS_CA` path config. TLS 1.3 minimum, no fallback. `server/config/tls.go` loader + validation. |
|
||||
| `MTLSNativeProvider` | ☐ | Reads `r.TLS.PeerCertificates[0]` — no header trust. `Subject.CommonName` → username. `sha256(Raw)` → `external_id`. Auto-provisions `auth_source=mtls`. Rename existing `mtls.go` → `mtls_proxy.go`. Extract shared helpers to `mtls_helpers.go`. |
|
||||
| Node-to-node mTLS | ☐ | Cluster heartbeat over mTLS. Node presents its cert when dialing peers. Single cert per node (ServerAuth + ClientAuth EKU). SAN includes hostname + IP. Peer verification against cluster CA pool. |
|
||||
| `armature-ca.sh` | ☐ | Shell wrapper around openssl. Three commands: `init` (CA keypair) · `issue-node --name <n> --san <addrs>` (365d) · `issue-user --cn <name>` (90d). All output PEM. |
|
||||
| Unit tests | ☐ | Ephemeral CA via `crypto/x509`. Fabricated `PeerCertificates`. Valid cert → user provisioned · no TLS → `ErrNoCert` · empty certs → `ErrNoCert` · same fingerprint → idempotent. |
|
||||
| Integration tests | ☐ | Real TLS listener on localhost. No cert / wrong CA / expired → TLS handshake rejected. Valid cert → 200, user created. Node-to-node: two in-process servers, distinct certs, same CA. Wrong-CA node rejected. |
|
||||
|
||||
### v0.6.6 — Final Hardening
|
||||
|
||||
Final pass before public release. Security, correctness, and developer experience.
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
@@ -139,7 +153,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 +173,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. |
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
111
docs/DESIGN-WORKFLOWS.md
Normal 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
|
||||
306
docs/DESIGN-native-mtls.md
Normal file
306
docs/DESIGN-native-mtls.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# DESIGN — Native mTLS
|
||||
|
||||
**Version:** v0.6.7
|
||||
**Status:** Proposed
|
||||
**Author:** Jeff / Claude session 2026-03-31
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
The existing `MTLSProxyProvider` assumes a TLS-terminating reverse proxy
|
||||
(Traefik, nginx, Istio) sits in front of the Go binary. Identity arrives
|
||||
via injected HTTP headers (`X-SSL-Client-CN`, etc.). The backend never
|
||||
participates in the TLS handshake.
|
||||
|
||||
A target deployment class — systemd units + podman on bare metal or VMs —
|
||||
has no reverse proxy. All connections must be mTLS end-to-end:
|
||||
|
||||
- **Client → server:** browser/CLI presents a user client cert
|
||||
- **Node → node:** cluster peers mutually authenticate for heartbeat,
|
||||
realtime pub/sub, and registry sync
|
||||
|
||||
The Go binary must terminate TLS itself and extract identity directly
|
||||
from the verified peer certificate.
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Certificate revocation (CRL/OCSP). Deferred — short-lived certs +
|
||||
restart is sufficient pre-1.0.
|
||||
- Automatic cert rotation. Operators restart the process after replacing
|
||||
certs on disk.
|
||||
- Acting as a CA at runtime. Cert issuance is an offline operator task.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### TLS Server Mode
|
||||
|
||||
New config knob `TLS_MODE` with three values:
|
||||
|
||||
| Value | Behavior |
|
||||
|----------|----------------------------------------------------|
|
||||
| `none` | Plain HTTP. Current default. Use behind a proxy. |
|
||||
| `server` | Server-side TLS only. No client cert required. |
|
||||
| `mtls` | Mutual TLS. Client cert required and verified. |
|
||||
|
||||
When `TLS_MODE` is `server` or `mtls`, the binary calls
|
||||
`http.Server.ListenAndServeTLS` with paths from `TLS_CERT` and `TLS_KEY`.
|
||||
|
||||
When `TLS_MODE` is `mtls`, `tls.Config` is built with:
|
||||
|
||||
```go
|
||||
&tls.Config{
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
ClientCAs: loadCACertPool(cfg.TLSCA),
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
```
|
||||
|
||||
TLS 1.3 is the floor. No cipher suite configuration exposed — Go's
|
||||
defaults for 1.3 are non-negotiable and correct.
|
||||
|
||||
### Auth Provider: MTLSNativeProvider
|
||||
|
||||
Completely separate from `MTLSProxyProvider`. No shared code paths for
|
||||
cert extraction. They share only downstream helpers.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Shared helpers │
|
||||
│ ParseDN() · FingerprintCert() · ProvisionUser() │
|
||||
└────────────────┬──────────────┬──────────────────┘
|
||||
│ │
|
||||
┌────────────┴───┐ ┌──────┴────────────┐
|
||||
│ MTLSProxy │ │ MTLSNative │
|
||||
│ Provider │ │ Provider │
|
||||
│ │ │ │
|
||||
│ Reads headers: │ │ Reads TLS: │
|
||||
│ X-SSL-Client-* │ │ PeerCertificates │
|
||||
└────────────────┘ └───────────────────┘
|
||||
```
|
||||
|
||||
**MTLSNativeProvider.Authenticate():**
|
||||
|
||||
```go
|
||||
func (p *MTLSNativeProvider) Authenticate(c *gin.Context, s store.Stores) (*Result, error) {
|
||||
if c.Request.TLS == nil || len(c.Request.TLS.PeerCertificates) == 0 {
|
||||
return nil, ErrNoCert
|
||||
}
|
||||
peer := c.Request.TLS.PeerCertificates[0]
|
||||
|
||||
cn := peer.Subject.CommonName // → username
|
||||
dn := peer.Subject.String() // → metadata
|
||||
fp := FingerprintCert(peer) // sha256(DER) → external_id
|
||||
|
||||
return p.resolveOrProvision(c.Request.Context(), s, cn, dn, fp)
|
||||
}
|
||||
```
|
||||
|
||||
`FingerprintCert` computes `sha256(cert.Raw)` hex-encoded. This is the
|
||||
stable identity anchor — certs can be reissued with the same CN but will
|
||||
get a new fingerprint, creating a new user unless the operator explicitly
|
||||
maps them.
|
||||
|
||||
**Why Option B (two providers, not a mode switch):**
|
||||
|
||||
The security properties differ fundamentally. The proxy provider trusts
|
||||
headers — if deployed without a proxy that strips client-injected headers,
|
||||
an attacker can forge identity by sending `X-SSL-Client-CN: admin`. The
|
||||
native provider trusts the TLS stack — identity is cryptographically
|
||||
verified before the HTTP layer runs. Mixing these behind a single
|
||||
code path with a runtime switch invites misconfiguration. Two types,
|
||||
selected at startup by `TLS_MODE`, eliminates the ambiguity.
|
||||
|
||||
### Node-to-Node mTLS
|
||||
|
||||
Each Armature node is both server and client:
|
||||
|
||||
- **Server:** accepts connections from peers and user clients on its
|
||||
listen address.
|
||||
- **Client:** dials peers for cluster registry heartbeat, realtime
|
||||
event forwarding, and (future) distributed task coordination.
|
||||
|
||||
When dialing a peer, the node presents its own cert:
|
||||
|
||||
```go
|
||||
peerTLS := &tls.Config{
|
||||
Certificates: []tls.Certificate{nodeKeypair},
|
||||
RootCAs: caCertPool,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{TLSClientConfig: peerTLS},
|
||||
}
|
||||
```
|
||||
|
||||
Both sides verify against the same CA. A node whose cert is not signed
|
||||
by the cluster CA cannot join. This replaces any need for shared secrets
|
||||
or bearer tokens in the cluster protocol.
|
||||
|
||||
**Interaction with cluster registry (DESIGN-cluster-registry.md):**
|
||||
|
||||
The existing design uses LISTEN/NOTIFY over PostgreSQL for node
|
||||
discovery and an UNLOGGED `node_registry` table for heartbeat. mTLS
|
||||
does not change the discovery mechanism — it secures the HTTP transport
|
||||
between nodes once they've discovered each other. The heartbeat sweep,
|
||||
self-eviction, and leaderless-by-default properties are orthogonal.
|
||||
|
||||
### Cert Topology
|
||||
|
||||
```
|
||||
cluster-ca.crt / cluster-ca.key ← generated once, lives on operator workstation
|
||||
├── node-1.crt + node-1.key ← SAN: node-1.internal, 10.0.0.1
|
||||
├── node-2.crt + node-2.key ← SAN: node-2.internal, 10.0.0.2
|
||||
├── user-jeff.crt + user-jeff.key ← CN=jeff, client auth only
|
||||
└── user-alice.crt + user-alice.key
|
||||
```
|
||||
|
||||
**Node certs** carry both `ServerAuth` and `ClientAuth` extended key
|
||||
usages. SANs include the node's hostname and IP — required because
|
||||
peers may dial by either.
|
||||
|
||||
**User certs** carry only `ClientAuth`. The CN becomes the username.
|
||||
Email can go in the Subject if desired (parsed by `ParseDN()`).
|
||||
|
||||
The CA private key **never** touches a running node. Nodes receive only:
|
||||
`cluster-ca.crt` (for verifying peers), their own `node-N.crt`, and
|
||||
their own `node-N.key`.
|
||||
|
||||
### Cert Provisioning Tooling
|
||||
|
||||
A shell script (`scripts/armature-ca.sh`) wrapping `openssl` is the
|
||||
KISS path. No new binary, no new dependency. Three commands:
|
||||
|
||||
```
|
||||
armature-ca init
|
||||
→ generates cluster-ca.crt + cluster-ca.key in ./ca/
|
||||
|
||||
armature-ca issue-node --name node-1 --san "node-1.internal,10.0.0.1"
|
||||
→ generates node-1.crt + node-1.key in ./nodes/
|
||||
|
||||
armature-ca issue-user --cn jeff [--email jeff@example.com]
|
||||
→ generates jeff.crt + jeff.key in ./users/
|
||||
```
|
||||
|
||||
The script generates a proper `openssl.cnf` fragment per invocation
|
||||
with the right SANs, EKUs, and validity period (default 365 days for
|
||||
nodes, 90 days for users). All output is PEM.
|
||||
|
||||
If we later want a Go-native tool (for cross-platform or embedding),
|
||||
it's a straightforward port — the `crypto/x509` stdlib does everything
|
||||
openssl does here.
|
||||
|
||||
---
|
||||
|
||||
## Config Surface
|
||||
|
||||
```env
|
||||
# TLS mode — controls whether the binary terminates TLS
|
||||
# none: plain HTTP (default, use behind a proxy)
|
||||
# server: TLS, no client cert required
|
||||
# mtls: mutual TLS, client cert required
|
||||
TLS_MODE=mtls
|
||||
|
||||
# Cert paths (required when TLS_MODE != none)
|
||||
TLS_CERT=/etc/armature/tls/node.crt
|
||||
TLS_KEY=/etc/armature/tls/node.key
|
||||
TLS_CA=/etc/armature/tls/ca.crt
|
||||
|
||||
# Listen address (default :8080 for none, :8443 for tls/mtls)
|
||||
LISTEN_ADDR=:8443
|
||||
|
||||
# Auth mode — when TLS_MODE=mtls, this should be mtls
|
||||
AUTH_MODE=mtls
|
||||
```
|
||||
|
||||
`TLS_MODE` and `AUTH_MODE` are independent knobs:
|
||||
|
||||
- `TLS_MODE=server` + `AUTH_MODE=builtin` = HTTPS with password login
|
||||
- `TLS_MODE=mtls` + `AUTH_MODE=mtls` = full mTLS identity
|
||||
- `TLS_MODE=none` + `AUTH_MODE=mtls` = proxy-terminated mTLS (existing behavior)
|
||||
|
||||
The third combination is the current `MTLSProxyProvider` path. The
|
||||
first is useful for deployments that want encrypted transport but
|
||||
password auth (e.g., single-node with Let's Encrypt).
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
**Unit tests (in-process, no network):**
|
||||
|
||||
Generate an ephemeral CA and certs using `crypto/x509` + `crypto/ecdsa`
|
||||
in `TestMain`. Tests call `MTLSNativeProvider.Authenticate()` with a
|
||||
fabricated `*http.Request` whose `TLS.PeerCertificates` is populated
|
||||
directly.
|
||||
|
||||
- Valid cert → user provisioned, correct CN/fingerprint
|
||||
- No TLS → `ErrNoCert`
|
||||
- Empty PeerCertificates → `ErrNoCert`
|
||||
- Second call with same fingerprint → same user returned (idempotent)
|
||||
- Different CN, same fingerprint → same user (cert reissue scenario)
|
||||
|
||||
**Integration tests (real TLS listener):**
|
||||
|
||||
Spin up `http.Server` with `tls.Config` on localhost. Dial with
|
||||
`http.Client` using test client certs.
|
||||
|
||||
- No client cert → TLS handshake rejected (Go returns it before handler)
|
||||
- Wrong CA → TLS handshake rejected
|
||||
- Valid cert → 200, user created
|
||||
- Expired cert → TLS handshake rejected
|
||||
|
||||
**Node-to-node integration:**
|
||||
|
||||
Two in-process servers with distinct node certs, same CA. Verify:
|
||||
|
||||
- Node A can heartbeat to Node B
|
||||
- Node with wrong-CA cert cannot dial either
|
||||
- Connection refused when cert SANs don't match dial address
|
||||
(if `tls.Config.ServerName` is set — TBD whether we enforce this
|
||||
for internal traffic or rely solely on CA verification)
|
||||
|
||||
---
|
||||
|
||||
## Migration Notes
|
||||
|
||||
No database schema changes. The `users` table already has `auth_source`
|
||||
and `external_id` columns from the v0.24.1 mTLS/OIDC work. The native
|
||||
provider populates them identically to the proxy provider.
|
||||
|
||||
The new code is:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `server/auth/mtls_native.go` | `MTLSNativeProvider` type |
|
||||
| `server/auth/mtls_helpers.go` | Shared: `ParseDN`, `FingerprintCert`, `resolveOrProvision` |
|
||||
| `server/auth/mtls_native_test.go` | Unit + integration tests |
|
||||
| `server/config/tls.go` | `TLSConfig` struct, loader, validation |
|
||||
| `scripts/armature-ca.sh` | Cert provisioning wrapper |
|
||||
|
||||
`server/auth/mtls.go` (existing) is renamed to `mtls_proxy.go` for
|
||||
clarity. No behavioral changes to the proxy provider.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **SAN enforcement on node-to-node:** Do we verify `ServerName`
|
||||
matches the dialed address, or just verify the cert is CA-signed?
|
||||
Strict SAN checking is more secure but makes IP changes painful.
|
||||
Recommendation: verify CA signature only for internal traffic;
|
||||
SAN checking for client-facing listen.
|
||||
|
||||
2. **Cert rotation without restart:** Not in scope for v0.6.7, but
|
||||
the `tls.Config.GetCertificate` / `GetConfigForClient` callbacks
|
||||
make it straightforward to add later — read cert from disk on each
|
||||
handshake (with caching). Worth noting in the design so we don't
|
||||
paint ourselves into a corner.
|
||||
|
||||
3. **PKCS#12 / combined files:** Some operators prefer `.p12` bundles
|
||||
over separate `.crt`/`.key` files. Low priority but trivial to add
|
||||
via `crypto/pkcs12.Decode`. Defer unless requested.
|
||||
119
docs/TUTORIAL-FIRST-EXTENSION.md
Normal file
119
docs/TUTORIAL-FIRST-EXTENSION.md
Normal 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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
function _escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function _attachHandlers(container, tableId, rawCSV) {
|
||||
const block = container.querySelector(`[data-csv-id="${tableId}"]`);
|
||||
if (!block) return;
|
||||
|
||||
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);
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
},
|
||||
// ── Styles ──────────────────────────────
|
||||
|
||||
_escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
_injectStyles() {
|
||||
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 });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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]');
|
||||
|
||||
@@ -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;
|
||||
function _escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ── Inject styles ──
|
||||
this._injectStyles();
|
||||
// ── KaTeX Library Loading ──────────────
|
||||
|
||||
// ── 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>
|
||||
`;
|
||||
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;
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
});
|
||||
}
|
||||
// ── Block Rendering ──────────────────────
|
||||
|
||||
// Process inline math in text content
|
||||
self._processInlineMath(container);
|
||||
}
|
||||
});
|
||||
|
||||
// Pre-load KaTeX
|
||||
this._loadKaTeX();
|
||||
},
|
||||
|
||||
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
|
||||
// ── Inline Math Processing ───────────────
|
||||
|
||||
async function _processInlineMath(container) {
|
||||
if (!container.textContent || !container.textContent.includes('$')) return;
|
||||
try { await _loadKaTeX(); } catch { return; }
|
||||
|
||||
try {
|
||||
await this._loadKaTeX();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk text nodes, skip code/pre/katex-already-rendered
|
||||
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') {
|
||||
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 walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return NodeFilter.FILTER_REJECT;
|
||||
const tag = parent.tagName;
|
||||
if (tag === 'CODE' || tag === 'PRE' || tag === 'SCRIPT' ||
|
||||
tag === 'TEXTAREA' || tag === 'STYLE') 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) _replaceInlineMath(textNode);
|
||||
}
|
||||
|
||||
for (const textNode of textNodes) {
|
||||
this._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;
|
||||
// ── Styles ──────────────────────────────
|
||||
|
||||
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';
|
||||
|
||||
// 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 });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
function _isDark() {
|
||||
return document.documentElement.dataset.theme === 'dark' ||
|
||||
document.documentElement.classList.contains('dark');
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
});
|
||||
|
||||
this._loadMermaid();
|
||||
},
|
||||
function _escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ── 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 });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) ───── */}}
|
||||
|
||||
136
src/js/__tests__/renderers.test.js
Normal file
136
src/js/__tests__/renderers.test.js
Normal 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
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
252
src/js/sw/sdk/markdown.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _escapeAttr(str) {
|
||||
if (!str) return '';
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// ── 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
136
src/js/sw/sdk/renderers.js
Normal 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 })),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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 = `
|
||||
|
||||
Reference in New Issue
Block a user