Compare commits
4 Commits
feat/268-c
...
feat/275-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba87f4ee24 | ||
|
|
5280392fc4 | ||
| 2524f39a36 | |||
|
|
ef173f022d |
@@ -197,6 +197,12 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
||||
|
||||
Run from the repo root unless noted. The dev workflow needs **Postgres (with the `pgvector` extension) and Redis** reachable per `.env` (copy `.env.example` → `.env`).
|
||||
|
||||
> **Bringing up a full local stand** (API + client + the separate realtime
|
||||
> collaboration process) has several non-obvious gotchas — a missing collab
|
||||
> server, `APP_SECRET` mismatch between processes, a stale `editor-ext` white-
|
||||
> screening the client, LAN exposure. See **[docs/dev-stand.md](docs/dev-stand.md)**
|
||||
> for the step-by-step and the traps.
|
||||
|
||||
```bash
|
||||
pnpm install # install all workspaces (uses pnpm patches; see package.json `pnpm.patchedDependencies`)
|
||||
pnpm dev # client (Vite) + server (Nest watch) concurrently — primary dev loop
|
||||
@@ -241,6 +247,8 @@ Migration files live in `apps/server/src/database/migrations/` and are named `YY
|
||||
- **API server** — `dist/main` (`apps/server/src/main.ts`), the Fastify HTTP app (`AppModule`).
|
||||
- **Collaboration server** — `dist/collaboration/server/collab-main` (`pnpm collab`), a Hocuspocus/Yjs WebSocket server (`apps/server/src/collaboration/`) handling real-time document editing, persistence, and page-history snapshots. It listens on `COLLAB_PORT` (default `3001`), separate from the API server's `PORT` (default `3000`), and shares state with the API server through Redis.
|
||||
|
||||
`pnpm dev` starts **only** the API server + client — the collaboration process is separate and must be started too, or the editor never connects. See **[docs/dev-stand.md](docs/dev-stand.md)** for running both locally (and why `APP_SECRET` must match between them).
|
||||
|
||||
The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes `robots.txt`, public share pages, and `mcp` from the prefix). A `preHandler` hook enforces that a resolved `workspaceId` exists for most `/api` routes (multi-tenant by hostname/subdomain via `DomainMiddleware`). `GET /api/sb/:id` (the anonymous blob-sandbox read route) is listed in that preHandler's `excludedPaths`, so it is exempt from workspace resolution and carries no session auth at all (its capability is the unguessable UUID + TTL + TLS) — unlike `/api/files/public/...`, which still resolves a workspace and requires a workspace-bound attachment JWT. Auth is JWT (cookie + bearer); authorization is **CASL** (`core/casl`) — every data access is scoped to the user's abilities.
|
||||
|
||||
### Module structure (server)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
// Covers the read-only render branch (PR #278): the language <Select> renders
|
||||
// only when `editor.isEditable`; in read-only the copy button still shows.
|
||||
// Mocks mirror the #146 structural harness (footnote-views.structure.test.tsx),
|
||||
// except Select becomes a detectable node so we can assert its presence/absence.
|
||||
vi.mock("@tiptap/react", () => ({
|
||||
NodeViewWrapper: ({ children }: any) => <div>{children}</div>,
|
||||
NodeViewContent: (props: any) => <div data-node-view-content="" {...props} />,
|
||||
}));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("@mantine/core", () => ({
|
||||
Group: ({ children }: any) => <div>{children}</div>,
|
||||
Select: () => <div data-testid="language-select" />,
|
||||
Tooltip: ({ children }: any) => <>{children}</>,
|
||||
ActionIcon: ({ children, onClick }: any) => (
|
||||
<button data-testid="copy-button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/common/copy-button", () => ({
|
||||
CopyButton: ({ children }: any) => children({ copied: false, copy: () => {} }),
|
||||
}));
|
||||
vi.mock("@tabler/icons-react", () => ({
|
||||
IconCheck: () => null,
|
||||
IconCopy: () => null,
|
||||
}));
|
||||
vi.mock("@/features/editor/components/code-block/mermaid-view.tsx", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
import CodeBlockView from "./code-block-view";
|
||||
|
||||
const makeProps = (isEditable: boolean) =>
|
||||
({
|
||||
node: { attrs: { language: "javascript" }, textContent: "", nodeSize: 1 },
|
||||
editor: {
|
||||
state: { selection: { from: 0, to: 0 } },
|
||||
isEditable,
|
||||
commands: {},
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
},
|
||||
extension: {
|
||||
options: { lowlight: { listLanguages: () => ["javascript", "python"] } },
|
||||
},
|
||||
getPos: () => 0,
|
||||
updateAttributes: () => {},
|
||||
deleteNode: () => {},
|
||||
}) as any;
|
||||
|
||||
describe("CodeBlockView language selector visibility (#278)", () => {
|
||||
it("renders the language selector when the editor is editable", () => {
|
||||
const { queryByTestId } = render(<CodeBlockView {...makeProps(true)} />);
|
||||
expect(queryByTestId("language-select")).not.toBeNull();
|
||||
expect(queryByTestId("copy-button")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hides the language selector in read-only but keeps the copy button", () => {
|
||||
const { queryByTestId } = render(<CodeBlockView {...makeProps(false)} />);
|
||||
expect(queryByTestId("language-select")).toBeNull();
|
||||
expect(queryByTestId("copy-button")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -50,10 +50,10 @@ export default function CodeBlockView(props: NodeViewProps) {
|
||||
{/* #146: the editable <pre><code> (contentDOM) MUST come first in the DOM.
|
||||
With the non-editable menu rendered before it, the browser's click
|
||||
hit-testing snapped the caret up one line. Render content first; the
|
||||
menu is rendered after it and lifted back above visually via flex
|
||||
`order: -1` (the `.codeBlock` wrapper is a flex column — see
|
||||
code-block.module.css). It stays fully in flow as a full-width row
|
||||
above the code: no overlay/absolute positioning. The second #146
|
||||
menu is rendered after it and floated into the top-right corner as an
|
||||
absolute overlay (see `.menuGroup` in code-block.module.css, anchored
|
||||
to the `position: relative` `.codeBlock` wrapper in code.css). It no
|
||||
longer takes a full-width row above the code. The second #146
|
||||
mitigation lives in editor-paste-handler.tsx (reflowAfterPaste). */}
|
||||
<pre
|
||||
spellCheck="false"
|
||||
@@ -67,22 +67,23 @@ export default function CodeBlockView(props: NodeViewProps) {
|
||||
<NodeViewContent as="code" className={`language-${language}`} />
|
||||
</pre>
|
||||
|
||||
<Group
|
||||
justify="flex-end"
|
||||
contentEditable={false}
|
||||
className={classes.menuGroup}
|
||||
>
|
||||
<Select
|
||||
placeholder="auto"
|
||||
checkIconPosition="right"
|
||||
data={extension.options.lowlight.listLanguages().sort()}
|
||||
value={languageValue}
|
||||
onChange={changeLanguage}
|
||||
searchable
|
||||
style={{ maxWidth: "130px" }}
|
||||
classNames={{ input: classes.selectInput }}
|
||||
disabled={!editor.isEditable}
|
||||
/>
|
||||
<Group contentEditable={false} className={classes.menuGroup}>
|
||||
{/* In read-only (published) there is no language selector at all —
|
||||
only the copy button. When editable the selector is hidden until
|
||||
the block is hovered/focused (or its dropdown is open) via the
|
||||
`.languageSelect` class (see code-block.module.css). */}
|
||||
{editor.isEditable && (
|
||||
<Select
|
||||
placeholder="auto"
|
||||
checkIconPosition="right"
|
||||
data={extension.options.lowlight.listLanguages().sort()}
|
||||
value={languageValue}
|
||||
onChange={changeLanguage}
|
||||
searchable
|
||||
style={{ maxWidth: "130px" }}
|
||||
classNames={{ root: classes.languageSelect, input: classes.selectInput }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CopyButton value={node?.textContent} timeout={2000}>
|
||||
{({ copied, copy }) => (
|
||||
|
||||
@@ -17,15 +17,37 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* #146: the menu now follows the <pre> in the DOM (so the editable contentDOM is
|
||||
FIRST and click hit-testing is correct). Lift it back ABOVE the code visually
|
||||
with flex `order` — the .codeBlock wrapper is a flex column (see code.css) —
|
||||
so the menu still reads as a row above the code, exactly as before, without
|
||||
sitting in-flow before the contentDOM. */
|
||||
/* #146: the menu follows the <pre> in the DOM (so the editable contentDOM is
|
||||
FIRST and click hit-testing is correct). Instead of sitting in-flow, it is
|
||||
floated into the top-right corner as an absolute overlay anchored to the
|
||||
`position: relative` .codeBlock wrapper (see code.css), so it no longer
|
||||
takes a full-width row above the code. The Mantine dropdown is portaled, so
|
||||
it is never clipped by the overlay. */
|
||||
.menuGroup {
|
||||
order: -1;
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
gap: 4px;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The language selector is hidden until the block is hovered, or the selector
|
||||
itself is focused / its dropdown is open. It keeps its width in the flex
|
||||
Group (only opacity toggles) so the copy button never jumps, and
|
||||
`pointer-events: none` while hidden lets clicks fall through to the code.
|
||||
`.codeBlock` is the global NodeViewWrapper class → use :global(). */
|
||||
.languageSelect {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
:global(.codeBlock):hover .languageSelect,
|
||||
.languageSelect:focus-within {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
.ProseMirror {
|
||||
.codeBlock {
|
||||
/* #146: flex column so the menu (rendered AFTER <pre> in the DOM, so the
|
||||
editable contentDOM is first) is lifted back above the code via `order`. */
|
||||
/* #146: flex column keeps the editable <pre> (first in the DOM so click
|
||||
hit-testing is correct) laid out above any Mermaid diagram. `position:
|
||||
relative` anchors the control panel, which is floated into the top-right
|
||||
corner as an absolute overlay (see `.menuGroup` in code-block.module.css). */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
padding: 4px;
|
||||
border-radius: var(--mantine-radius-default);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
|
||||
135
docs/dev-stand.md
Normal file
135
docs/dev-stand.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Running a local dev stand
|
||||
|
||||
How to bring up a working local instance (API + client + realtime collaboration)
|
||||
and the non-obvious gotchas that will otherwise eat an hour. Written from real
|
||||
setup pain — read the **Gotchas** section before you start.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node 20+ / pnpm 10+.**
|
||||
- **Postgres with pgvector.** Use the `pgvector/pgvector` image (e.g.
|
||||
`pgvector/pgvector:pg18`). The stock `postgres` image will FAIL the
|
||||
`CREATE EXTENSION vector` migration — the RAG feature stores embeddings in
|
||||
`page_embeddings`.
|
||||
- **Redis** — backs caching, BullMQ queues, the Socket.IO adapter, and collab
|
||||
sync.
|
||||
|
||||
## 1. Environment (`.env`)
|
||||
|
||||
The client (`apps/client/vite.config.ts`) and both server processes read env via
|
||||
`envPath` → the **workspace root `.env`**. Keep a single source of truth. Minimum:
|
||||
|
||||
```dotenv
|
||||
APP_URL=http://localhost:3000
|
||||
PORT=3000
|
||||
APP_SECRET=<one long secret — SAME value everywhere, see gotcha #3>
|
||||
DATABASE_URL="postgresql://<user>:<pass>@localhost:5432/<db>?schema=public"
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
COLLAB_URL=http://localhost:3001 # where the CLIENT connects for realtime
|
||||
COLLAB_PORT=3001 # where the COLLAB server listens
|
||||
STORAGE_DRIVER=local
|
||||
DISABLE_TELEMETRY=true
|
||||
```
|
||||
|
||||
> If you also keep an `apps/server/.env`, its `APP_SECRET` **must match** the
|
||||
> root one (see gotcha #3).
|
||||
|
||||
## 2. Migrations
|
||||
|
||||
Migrations do **not** auto-run in local dev. After a fresh checkout or switching
|
||||
branches, apply them yourself or endpoints touching a new column/table will 500:
|
||||
|
||||
```bash
|
||||
pnpm --filter server migration:latest
|
||||
```
|
||||
|
||||
## 3. Bring it up — THREE processes, not two
|
||||
|
||||
`pnpm dev` starts only the **API server** (Nest, `:3000`) and the **client**
|
||||
(Vite). Realtime collaboration is a **separate process** and `pnpm dev` does NOT
|
||||
start it. You need all three:
|
||||
|
||||
```bash
|
||||
# 1) API + client (from the repo root)
|
||||
pnpm dev
|
||||
# → API http://localhost:3000
|
||||
# → client http://localhost:5173 (Vite; localhost-only by default)
|
||||
|
||||
# 2) Collaboration server — SEPARATE process. Build first (see gotcha #2), then:
|
||||
pnpm --filter server build # produces dist/collaboration/server/collab-main.js
|
||||
pnpm collab:dev # node dist/.../collab-main → listens on :3001 (0.0.0.0)
|
||||
```
|
||||
|
||||
Without step 2 the editor shows **"Real-time editor connection lost. Retrying…"**,
|
||||
stays in read-only *static* mode, and anything that only mounts in the *live*
|
||||
editor won't appear.
|
||||
|
||||
## Seeding a login
|
||||
|
||||
Register through the UI, or reset an existing user's password directly in the DB
|
||||
(the server hashes with `bcrypt`):
|
||||
|
||||
```js
|
||||
// node -e '...' with pg + bcrypt from the repo's node_modules
|
||||
const bcrypt = require("bcrypt");
|
||||
const { Client } = require("pg");
|
||||
(async () => {
|
||||
const hash = await bcrypt.hash("demopass", 10);
|
||||
const c = new Client({ /* DATABASE_URL parts */ });
|
||||
await c.connect();
|
||||
await c.query("update users set password=$1 where email=$2", [hash, "admin@example.com"]);
|
||||
await c.end();
|
||||
})();
|
||||
```
|
||||
|
||||
> **Use a simple one-word password with no special characters** (e.g. `demopass`,
|
||||
> not `Str0ng!Pass@2026`). Demo/test credentials get passed through shells, JSON
|
||||
> payloads, and URLs by scripts and automation, where `!` `@` `$` `&` etc. get
|
||||
> mangled or need escaping — a plain alphanumeric word avoids a whole class of
|
||||
> "wrong password" confusion.
|
||||
|
||||
## Gotchas (the грабли)
|
||||
|
||||
1. **Collaboration is a third process.** `pnpm dev` runs API + client only.
|
||||
Start `pnpm collab:dev` (on `:3001`) separately or the live editor never
|
||||
connects. The client connects to `COLLAB_URL` directly (default
|
||||
`http://localhost:3001`), NOT through the Vite `/collab` proxy — the API
|
||||
server on `:3000` does **not** serve the collab websocket.
|
||||
|
||||
2. **The collab server must be built — you can't run it from source.**
|
||||
`collab:dev` runs `node dist/collaboration/server/collab-main.js`, so run
|
||||
`pnpm --filter server build` first. Running the entry via `tsx`/`ts-node`
|
||||
fails with a NestJS DI error ("dependency … appears to be undefined at
|
||||
runtime") because direct TS execution doesn't emit the decorator metadata the
|
||||
built output has.
|
||||
|
||||
3. **`APP_SECRET` must be identical for the API server and the collab server.**
|
||||
The API issues a collab-token (JWT signed with `APP_SECRET`); the collab
|
||||
server validates it with `APP_SECRET`. If they load different values (e.g. a
|
||||
root `.env` and an `apps/server/.env` with different secrets), every realtime
|
||||
connection is rejected with **`[onAuthenticate] Invalid collab token`** and
|
||||
the editor shows "connection lost". Keep one secret everywhere.
|
||||
|
||||
4. **Vite binds localhost only.** To reach the stand from another machine on the
|
||||
LAN, start the client with `--host` (`pnpm --filter client exec vite --host`)
|
||||
and use the box's LAN IP. The `/api`, `/socket.io`, and `/collab` Vite proxies
|
||||
forward to `APP_URL`, so the API just works over the LAN; realtime needs
|
||||
`COLLAB_URL` reachable from the browser (point it at the LAN IP:3001, and run
|
||||
collab on `0.0.0.0` — it does by default).
|
||||
|
||||
5. **A stale `@docmost/editor-ext` white-screens the client.** The client imports
|
||||
from `@docmost/editor-ext` (a workspace package). If that package's source is
|
||||
behind (missing a newer export, e.g. `Spoiler`), the client dies at load with
|
||||
*"The requested module … does not provide an export named 'Spoiler'"* → blank
|
||||
page. Make sure the workspace `packages/editor-ext` is current for the branch
|
||||
you're running (a stale sibling checkout resolved through a shared
|
||||
`node_modules` symlink is the usual cause).
|
||||
|
||||
6. **pgvector, not stock postgres** (see Prerequisites) — the `vector` extension
|
||||
migration fails otherwise.
|
||||
|
||||
7. **Migrations don't auto-run in dev** — run `migration:latest` after every pull
|
||||
or branch switch.
|
||||
|
||||
See also the **Commands** and **Architecture → Two server processes** sections in
|
||||
[`AGENTS.md`](../AGENTS.md).
|
||||
Reference in New Issue
Block a user