diff --git a/workspace/_assets_/nav-data.js b/workspace/_assets_/nav-data.js index 96f53d3..7d82f7c 100644 --- a/workspace/_assets_/nav-data.js +++ b/workspace/_assets_/nav-data.js @@ -10,9 +10,10 @@ var NAV_DATA = { title: 'Guides', path: 'guides/index.html', children: [ - { title: 'Writing TypeScript Code', path: 'guides/writing-typescript-code/index.html' }, - { title: 'Writing Editor Panels', path: 'guides/writing-editor-panels/index.html' }, - { title: 'Locales', path: 'guides/locales/index.html' }, + { title: 'Writing TypeScript Code', path: 'guides/writing-typescript-code/index.html' }, + { title: 'Writing Editor Panels', path: 'guides/writing-editor-panels/index.html' }, + { title: 'Writing Backend Routes', path: 'guides/writing-backend-routes/index.html' }, + { title: 'Locales', path: 'guides/locales/index.html' }, ] }, { @@ -23,6 +24,13 @@ var NAV_DATA = { { title: 'Update Outline', path: 'actions/update-outline/index.html' }, ] }, + { + title: 'Reference', + path: 'reference/index.html', + children: [ + { title: 'Editor Singleton & Client/Server Split', path: 'reference/editor-singleton/index.html' }, + ] + }, { title: 'History', path: 'history/index.html', diff --git a/workspace/guides/index.html b/workspace/guides/index.html index 8d1931e..31267df 100644 --- a/workspace/guides/index.html +++ b/workspace/guides/index.html @@ -29,6 +29,30 @@
+
+ How to create a new panel for the editor: component files, toolbar
+ requirement, connectedCallback initialisation, duplicate
+ support, registration in the tab container menu, and responsive layout rules.
+
+ Read the guide +
+
+ How to add a new API endpoint to the Express server: router file layout,
+ auth middleware, reading request data, JSON file storage, streaming responses,
+ and mounting in server/index.ts.
+
+ Read the guide +
+diff --git a/workspace/guides/writing-backend-routes/index.html b/workspace/guides/writing-backend-routes/index.html new file mode 100644 index 0000000..f5a662a --- /dev/null +++ b/workspace/guides/writing-backend-routes/index.html @@ -0,0 +1,190 @@ + + +
+ + +How to add a new API endpoint to the Express server: file layout, auth, request handling, file storage, and mounting.
+
+ Every API feature lives in its own router file under server/routes/.
+ The router is created with Express's Router(), handlers are attached
+ to it, and it is exported and mounted in server/index.ts at an
+ /api/<name> path. The server runs via ts-node
+ — there is no separate compilation step for server-side TypeScript.
+ See server/routes/layout.ts (simple JSON CRUD) and
+ server/routes/rojos.ts (streaming) as reference implementations.
+
Create server/routes/<name>.ts. All route files follow the same skeleton:
import { Router } from 'express';
+import { requireAuth } from '../middleware/auth';
+
+const router = Router();
+router.use( requireAuth );
+
+router.get( '/', ( req, res ) =>
+{
+ res.json( { ok: true } );
+} );
+
+export default router;
+
+ requireAuth is applied with router.use so it covers
+ every handler in the file. If only some routes need auth, apply it per-handler
+ as a middleware argument instead.
+
+ The session is typed via a declare module in
+ server/middleware/auth.ts. The available fields are
+ req.session.userId and req.session.username, both
+ string | undefined. After requireAuth they are
+ guaranteed to be set — use the non-null assertion (!) freely:
+
const userId = req.session.userId!;
+const username = req.session.username!;
+
+ Both body parsers are applied globally in server/index.ts
+ — no per-route setup is needed:
+
Content-Type: application/json) — available as req.body, cast to your type: req.body as { field: string }Content-Type: text/plain) — available as req.body (a string). Used for raw file content saves.req.query.paramName as stringreq.params.paramName
+ There is no database. Persistent data goes in subdirectories of
+ storage/. Reference the path relative to the compiled output
+ location using __dirname:
+
import fs from 'fs';
+import path from 'path';
+
+const DATA_DIR = path.join( __dirname, '..', '..', 'storage', 'my-feature' );
+
+function ensureDir(): void
+{
+ if ( !fs.existsSync( DATA_DIR ) ) fs.mkdirSync( DATA_DIR, { recursive: true } );
+}
+
+function readData( id: string ): any
+{
+ ensureDir();
+ const fp = path.join( DATA_DIR, id + '.json' );
+ if ( !fs.existsSync( fp ) ) return null;
+ return JSON.parse( fs.readFileSync( fp, 'utf8' ) );
+}
+
+function writeData( id: string, data: any ): void
+{
+ ensureDir();
+ fs.writeFileSync( path.join( DATA_DIR, id + '.json' ), JSON.stringify( data ), 'utf8' );
+}
+
+ IDs should be UUIDs from crypto.randomUUID(), never user-supplied
+ strings used directly as filenames. If you must derive a filename from user
+ input, sanitise it: s.replace( /[^a-zA-Z0-9_-]/g, '_' ).
+
Standard patterns used across the codebase:
+res.json( { ok: true } ); // success, no payload
+res.json( data ); // success with payload
+res.status( 400 ).json( { error: 'Bad input' } ); // client error
+res.status( 401 ).json( { error: 'Not authenticated' } ); // handled by requireAuth
+res.status( 404 ).json( { error: 'Not found' } );
+res.status( 500 ).json( { error: String( err ) } );
+
+ Always return after sending a response inside a conditional block
+ to prevent Express from throwing "headers already sent":
+
if ( !id ) { res.status( 400 ).json( { error: 'Missing id' } ); return; }
+ + For SSE / streaming (e.g. AI output), set the headers explicitly, flush them, + write newline-delimited JSON chunks, then end the response: +
+res.setHeader( 'Content-Type', 'text/event-stream' );
+res.setHeader( 'Cache-Control', 'no-cache' );
+res.setHeader( 'Connection', 'keep-alive' );
+res.flushHeaders();
+
+for await ( const chunk of someStream )
+{
+ res.write( JSON.stringify( { type: 'DATA', value: chunk } ) + '\n' );
+}
+
+res.write( JSON.stringify( { type: 'DONE' } ) + '\n' );
+res.end();
+
+ Wrap the whole block in try/catch and check
+ !res.headersSent before sending an error response, since headers
+ may already be flushed by the time an exception occurs.
+
+ Open server/index.ts and add two lines — an import and a
+ app.use call — following the existing pattern:
+
import myRouter from './routes/my-feature';
+
+app.use( '/api/my-feature', myRouter );
+
+ Restart the server after this change. No build step is needed —
+ ts-node compiles on the fly.
+
How to create a new panel that lives inside a tab container in the editor.
+
+ Every editor panel is a custom element registered with
+ customElements.define. It lives inside a
+ <tab-container>, gets its own tab, and can be opened,
+ closed, dragged to another container, and duplicated. A panel must have a
+ toolbar, must be self-initialising on connectedCallback, and
+ must be registered in TabContainer.openMenu so users can add it.
+ See html-editor-panel and rojo-chat-panel as
+ reference implementations.
+
+ Create two files following the naming convention: +
+src/components/<name>/<name>.ts ← TypeScript source
+public/components/<name>/<name>.css ← CSS (maintained directly here, not compiled)
+
+ The TypeScript compiles to public/components/<name>/<name>.js
+ via tsconfig.client.json (npm run build).
+
+ Extend HTMLElement and guard connectedCallback
+ with an _initialized flag so it only runs once. Set up the
+ panel's full HTML structure inside connectedCallback:
+
class MyPanel extends HTMLElement
+{
+ _initialized = false;
+
+ connectedCallback(): void
+ {
+ if ( this._initialized ) return;
+ this._initialized = true;
+
+ this.className = 'my-panel';
+ this.innerHTML = `
+ <div class="mp-toolbar">…</div>
+ <div class="mp-content">…</div>
+ `;
+ }
+}
+
+customElements.define( 'my-panel', MyPanel );
+
+ All state that should be independent per instance (IDs, history, etc.)
+ must be initialised inside connectedCallback, not at class
+ level — because Duplicate creates a fresh element via
+ document.createElement, which triggers
+ connectedCallback again on the new instance.
+
+ Every panel must have a toolbar as its first child. The toolbar is a flex
+ row, fixed height, that does not shrink. Typical layout: icon or label on
+ the left, spacer (flex: 1), action buttons on the right.
+ Button style should match the other panels (see
+ html-editor-panel.css for the canonical colours and sizing).
+
/* in public/components/my-panel/my-panel.css */
+my-panel {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background: #0f1117;
+}
+
+.mp-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 6px 8px;
+ background: #13151f;
+ border-bottom: 1px solid #2a2d3a;
+ flex-shrink: 0;
+}
+
+.mp-content {
+ flex: 1;
+ overflow: auto;
+}
+ addContextMenuEntries
+ Import and implement the EditorPanel interface from
+ tab-container.ts. This allows the panel to append its own
+ entries to the tab container's context menu when it is the active tab.
+ At minimum, add a read-only label identifying the panel type or its
+ current state.
+
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
+
+// inside the class:
+addContextMenuEntries( dir: ContextMenuDirectory ): void
+{
+ dir.add( new ContextMenuReadOnlyEntry( dir, 'My Panel' ) );
+}
+
+ Open src/components/tab-container/tab-container.ts and add
+ an entry to the panelTypes array inside openMenu:
+
{ label: 'My Panel', panelType: 'my-panel', tag: 'my-panel' },
+
+ label — the text shown in the Add submenu.
+ panelType — internal identifier (used for dirty-state tracking and queries).
+ tag — the custom element tag passed to document.createElement.
+
+ Duplicate works automatically once the panel is registered — + it calls the same factory, creating a new independent instance. +
+
+ Add the CSS link and module script to public/editor.html:
+
<link rel="stylesheet" href="/components/my-panel/my-panel.css">
+<script type="module" src="/components/my-panel/my-panel.js"></script>
+
+ Using a UMD vendor library (e.g. CodeMirror, markdown-it)
++ When a panel depends on a third-party library that ships as a UMD bundle + (a single JS file that sets a global variable), follow this pattern: +
+public/vendor/.<script> tag in editor.html
+ before the module script. Order matters — the global must
+ exist before the module runs:
+ <script src="/vendor/some-lib.min.js"></script>
+<script type="module" src="/components/my-panel/my-panel.js"></script>
+ declare const SomeLib: any;
+
+ Existing examples: markdown-it (used in rojo-chat-panel),
+ CodeMirror 5 and its language mode files (used in code-panel).
+
panel:label-change
+ When a panel loads a file it should update its own tab title to reflect the
+ open filename. Dispatch a bubbling CustomEvent named
+ panel:label-change with a detail.label string —
+ the TabContainer listens for it and updates the tab automatically:
+
_updateTabLabel( path: string ): void
+{
+ const name = path ? path.slice( path.lastIndexOf( '/' ) + 1 ) : '';
+ this.dispatchEvent( new CustomEvent( 'panel:label-change',
+ {
+ bubbles: true,
+ detail: { label: '📄 ' + name },
+ } ) );
+}
+
+ Call this from your _loadDocument (or equivalent) method, after
+ setting this.currentPath. The event must bubble so it reaches
+ the ancestor <tab-container>.
+
+ The initial tab label (shown before any file is opened) is set in
+ TabContainer.openMenu via the label field of the
+ panelTypes entry — that is the only place the label is set
+ without this event.
+
+ The editor shell handles the outer panel arrangement and switching between
+ landscape and portrait modes. Inside the panel, use height: 100%
+ on the root element and a column flex layout so the panel always fills the
+ available space. Avoid fixed pixel heights for content zones — use
+ flex: 1 for the scrollable area and flex-shrink: 0
+ for the toolbar and any fixed-height input areas.
+
+ If the panel has an input area (like a chat box), place it as the last
+ child and give it flex-shrink: 0. The scrollable content
+ area above it takes flex: 1. On narrow screens the input
+ area will naturally occupy more vertical proportion — keep it compact
+ (avoid large padding or decorative margins) so content remains visible.
+
+ Technical reference for core systems: the Editor singleton, events, and the client/server compilation split.
+
+
+ Read more about the reference
+
+ The Editor singleton (src/editor/Editor.ts) is the
+ central hub of the frontend editor — it owns all open document state, the
+ FileEditorRegistry, and the five events that panels and the tab
+ container subscribe to (onDocumentOpened,
+ onDocumentDirty, onDocumentSaved,
+ onFilesChanged, onFileTypeUnknown). For the full
+ event reference and the TypeScript compilation split between client and server,
+ see the
+ Editor Singleton reference.
+
diff --git a/workspace/reference/editor-singleton/index.html b/workspace/reference/editor-singleton/index.html new file mode 100644 index 0000000..6b200ee --- /dev/null +++ b/workspace/reference/editor-singleton/index.html @@ -0,0 +1,285 @@ + + +
+ + +
+ The Editor class is the central hub of the frontend editor.
+ This page documents its events, methods, and properties, and explains the
+ TypeScript compilation split between client and server code.
+
+ Editor lives at src/editor/Editor.ts and compiles
+ to public/editor/Editor.js. It is a singleton accessed everywhere
+ via Editor.get(). It owns all open document state, the
+ FileEditorRegistry, and the events that panels and the tab
+ container subscribe to. It is the only place where files are fetched from
+ the server and where saves are sent back.
+
+ Never import Editor in server-side code. It is a
+ browser-only module. The split between client and server code is described in
+ the section below.
+
import { Editor } from '../../editor/Editor.js';
+
+const editor = Editor.get();
+
+ The .js extension is required in all client-side imports because
+ the TypeScript output is consumed directly by the browser as ES modules
+ (no bundler).
+
| Property | +Type | +Description | +
|---|---|---|
projectId |
+ string |
+ UUID of the open project. Set by EditorShell on init from the URL query string. |
+
projectName |
+ string |
+ Display name of the open project. Set by EditorShell on init. |
+
openDocs |
+ Map<string, { content, dirty }> |
+ In-memory cache of all documents fetched this session. Keyed by file path. | +
activeDoc |
+ string | null |
+ File path of the most recently opened document. | +
fileEditorRegistry |
+ FileEditorRegistry |
+ Suffix-to-editor-tag resolver. Loaded lazily on first openDocument call. |
+
Editor.get(): Editor
Returns the singleton instance, creating it on first call.
+ +openDocument( filePath: string ): Promise<void>
+ The main entry point for opening a file. Loads the registry (once), resolves
+ the editorTag for the file's suffix. If no tag is found, dispatches
+ onFileTypeUnknown and returns early. Otherwise fetches the file
+ content (cached after first fetch), then dispatches onDocumentOpened.
+
markDirty( filePath: string, content: string ): void
+ Called by a panel whenever the user edits content. Updates the in-memory cache
+ and dispatches onDocumentDirty. The tab container uses this to
+ show the dirty dot.
+
save( filePath: string ): Promise<void>
+ PUTs the cached content to /api/files/${projectId}/${filePath}
+ with Content-Type: text/plain. Clears the dirty flag and
+ dispatches onDocumentSaved.
+
+ All events use EventSlot from the shared library —
+ not DOM events and not a pub/sub bus. Add a listener with
+ Editor.get().onSomeEvent.addListener( e => ... ).
+ Listeners are called synchronously when the event is dispatched.
+
onDocumentOpened — EventSlot<DocumentOpenedEvent>interface DocumentOpenedEvent {
+ path: string; // file path relative to project root
+ content: string; // raw file content
+ editorTag: string; // custom element tag resolved by FileEditorRegistry
+}
+
+ Fired after the file is fetched and the editor tag is resolved. Every editor
+ panel listens to this. Panels must check editorTag and
+ return early if it does not match their own tag — all panels receive
+ every event.
+
Editor.get().onDocumentOpened.addListener( ( e ) =>
+{
+ if ( 'my-panel' !== e.editorTag ) return;
+ this._loadDocument( e.path, e.content );
+} );
+ onDocumentDirty — EventSlot<DocumentPathEvent>interface DocumentPathEvent { path: string; }
+
+ Fired by markDirty. The TabContainer listens and
+ sets tab.dirty = true for the tab whose panel's
+ currentPath matches. Panels do not need to listen to this
+ themselves — they call markDirty and update their own Save button.
+
onDocumentSaved — EventSlot<DocumentPathEvent>
+ Fired by save after a successful PUT. The TabContainer
+ listens and clears the dirty dot for the matching tab.
+
onFilesChanged — EventSlot<void>
+ Fired with no payload when the file tree changes (file or folder created,
+ renamed, or deleted). FileTreePanel listens and re-fetches the
+ tree. Dispatch it after any operation that modifies the filesystem:
+
Editor.get().onFilesChanged.dispatch();
+ onFileTypeUnknown — EventSlot<DocumentPathEvent>
+ Fired when openDocument is called for a file whose suffix has no
+ entry in FileEditorRegistry. FileTreePanel listens
+ and shows a 3-second error banner. No file content is fetched when this fires.
+
+ Client and server TypeScript are compiled independently and must never import + from each other's side. +
+| Side | +Source | +Output | +How it runs | +
|---|---|---|---|
| Client | +src/ |
+ public/ |
+ npm run build → tsc --build tsconfig.client.json |
+
| Server | +server/ |
+ none (in-process) | +npm start → ts-node with tsconfig.ts-node.json |
+
| Library (browser) | +src/library-ts/browser/ |
+ public/library-ts/browser/ |
+ Compiled via TypeScript project references as part of npm run build |
+
+ Component CSS is written directly in
+ public/components/<name>/<name>.css and is never
+ processed by TypeScript. Do not put CSS files in src/.
+ Edit the file in public/ directly; the browser picks it up
+ on next reload with no build step.
+
npm run build, then reload the browser.npm start). No build step.
+ Client TypeScript uses module: ESNext and
+ moduleResolution: bundler. There is no bundler — the browser
+ receives individual .js files as ES modules.
+ All imports in client code must include the .js
+ extension, even though the source files end in .ts:
+
import { Editor } from '../../editor/Editor.js';
+import { EventSlot } from '../library-ts/browser/events/EventSlot.js';
+
+ TypeScript resolves these correctly during compilation because
+ moduleResolution: bundler allows importing .js
+ paths that correspond to .ts source files.
+
Technical reference for the core systems in Roject. Read the relevant page before touching the system it describes.
+
+ The Editor singleton is the central hub of the frontend — it owns
+ all open document state, the file editor registry, and the events that panels
+ and containers listen to. This page also documents the TypeScript compilation
+ split between client and server code.
+