Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/writer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@lezer/highlight": "^1.2.3",
"@lezer/markdown": "^1.4.3",
"@mdbase-dev/connect": "0.1.0-beta.125",
"@mdbase-dev/sdk": "file:vendor/mdbase-dev-sdk-c573c96.tgz",
"@mdbase-dev/ui": "0.1.0-beta.125",
"@mdbase-writer/core": "workspace:*",
"@myriaddreamin/typst-ts-renderer": "0.7.0",
Expand Down
52 changes: 48 additions & 4 deletions apps/writer/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
// Top level: pick the backend (Connect, or the development demo), then show
// Top level: pick the backend (Connect, the development demo, or the opt-in
// mdbase-next backend with `?next` / `?demo=next`), then show
// the manuscript list or an open manuscript. The open manuscript lives in the
// URL (`?manuscript=path`) so reloads and links land in the same place.
import { externalStore } from "@mdbase-dev/connect";
import { Component, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react";

import { ConnectBackend } from "./backend/connect.js";
import type { NextBackend } from "./backend/next.js";
import type { NextProblem } from "./backend/next-errors.js";
import type { WriterBackend } from "./backend/types.js";
import { createWriterSession, type WriterSession } from "./connect/session.js";
import { appUrls } from "./apps.js";
import { AppSwitcher } from "@mdbase-dev/ui/app-switcher";
import { FeedbackButton, useFeedback } from "@mdbase-dev/ui/feedback";
import { useWriterFeedbackContext } from "./FeedbackRoot.js";
import { ConnectGate } from "./ui/ConnectGate.js";
import { NextGate, NextStatus } from "./ui/NextGate.js";
import { CollectionPicker } from "./ui/CollectionPicker.js";
import { Home } from "./ui/Home.js";
import { Dialog } from "@mdbase-dev/ui/dialog";
Expand All @@ -26,6 +30,8 @@ import { ManuscriptWorkspace } from "./workspace/workspace.js";

const params = new URL(location.href).searchParams;
const demoRequested = (import.meta.env.DEV || import.meta.env.VITE_WRITER_DEMO === "1") && params.has("demo");
// Opt-in: an mdbase-next collection over the relay. The default stays Connect.
const nextRequested = (import.meta.env.DEV || import.meta.env.VITE_WRITER_NEXT === "1") && params.has("next");

function useManuscriptParam(mayLeave: () => Promise<boolean>): [string | null, (path: string | null, force?: boolean) => void] {
const [value, setValue] = useState(() => new URL(location.href).searchParams.get("manuscript"));
Expand Down Expand Up @@ -82,7 +88,7 @@ export function App() {
<ThemeSelect className="topbar-theme" value={theme} onChange={setTheme} />
<FeedbackButton />
</header>
<ErrorBoundary onError={() => reportError({ code: "unknown_error" })}>{demoRequested ? <DemoRoot /> : <ConnectRoot />}</ErrorBoundary>
<ErrorBoundary onError={() => reportError({ code: "unknown_error" })}>{demoRequested ? <DemoRoot /> : nextRequested ? <NextRoot /> : <ConnectRoot />}</ErrorBoundary>
</div>
</TopbarSlot.Provider>
</ThemeChoice.Provider>
Expand Down Expand Up @@ -169,7 +175,9 @@ function DemoRoot() {
if (!live) return;
const b = import.meta.env.DEV && params.get("demo") === "large"
? await (await import("./backend/large-demo.js")).createLargeDemoBackend()
: await createDemoBackend();
: params.get("demo") === "next"
? await (await import("./backend/next-demo.js")).createNextDemoBackend()
: await createDemoBackend();
if (live) { owned = b; setBackend(b); }
else b.dispose();
}).catch((error: unknown) => { if (live) setProblem(error instanceof Error ? error.message : String(error)); });
Expand All @@ -180,7 +188,41 @@ function DemoRoot() {
}, []);
if (problem) return <ConnectLayout app="writer" title="The demo could not be loaded" error={problem}><button type="button" className="mdbase-button" onClick={() => location.reload()}>Retry loading</button></ConnectLayout>;
if (!backend) return <OpeningScreen app="writer" title="Opening the demo collection" />;
return <Manuscripts backend={backend} />;
return <>{backend.kind === "next" && <NextStatus backend={backend as NextBackend} />}<Manuscripts backend={backend} /></>;
}

/**
* `?next&collection=<id>&grant=<id>`: a collection on mdbase-next. The route
* comes from a proposed control-plane endpoint (connect/next-control.ts).
*/
function NextRoot() {
const [backend, setBackend] = useState<NextBackend | null>(null);
const [waiting, setWaiting] = useState<NextProblem | undefined>();
const [problem, setProblem] = useState<NextProblem | undefined>();
useEffect(() => {
const abort = new AbortController();
let owned: NextBackend | undefined;
const collection = params.get("collection");
// The SDK loads only in this mode.
const errors = import("./backend/next-errors.js");
void (async () => {
if (!collection) throw new Error("Open Writer with ?next&collection=<collection ID>&grant=<grant ID>.");
const [{ connectNext, proposedControlPlane }, { NextBackend }, { problemFrom }] = await Promise.all([import("./connect/next-control.js"), import("./backend/next.js"), errors]);
const control = proposedControlPlane({ serverUrl: params.get("server") ?? import.meta.env.VITE_MDBASE_CONNECT_URL ?? "https://connect.mdbase.dev", grant: params.get("grant") });
const client = await connectNext({ collection, control, signal: abort.signal, onWaiting: (why) => setWaiting(problemFrom(why)) });
if (abort.signal.aborted) { client.close(); return; }
owned = new NextBackend(client, { collectionName: params.get("name") ?? "Collection", draftNamespace: `next:${collection}`, ownsClient: true });
setBackend(owned);
})().catch(async (error: unknown) => {
const { problemFrom } = await errors;
if (abort.signal.aborted) return;
const mapped = problemFrom(error);
setProblem(error instanceof Error && !("code" in error) ? { ...mapped, message: error.message } : mapped);
});
return () => { abort.abort(); owned?.dispose(); };
}, []);
if (!backend) return <NextGate waiting={waiting} problem={problem} />;
return <><NextStatus backend={backend} /><Manuscripts backend={backend} /></>;
}

function downloadLocalDrafts(workspace: ManuscriptWorkspace): void {
Expand All @@ -204,6 +246,8 @@ function Manuscripts({ backend, collectionPicker }: { backend: WriterBackend; co
return new Promise<boolean>((finish) => setNavigation({ message: saved.message, finish }));
});
const workspace = useOwned(() => (path ? new ManuscriptWorkspace(backend, path) : null), (w) => void w.dispose(), [backend, path]);
// A new manuscript whose path was taken at the log moved: follow it.
useEffect(() => backend.onRecordMoved?.(({ from, to }) => { if (from === path) setPath(to, true); }), [backend, path]);
useWriterFeedbackContext(path ? "manuscript" : "manuscripts", backend.collectionName);
currentWorkspace.current = workspace;
(window as unknown as { writer?: unknown }).writer = { backend, workspace };
Expand Down
54 changes: 54 additions & 0 deletions apps/writer/src/backend/next-demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// The `?demo=next` collection: the bundled demo manuscripts on the SDK's
// in-memory replica, read and written through NextBackend. No account, relay
// or device. Writes stay pending for a moment before they confirm, as they
// would on a real replica.
import { connect, toValue, type MdbaseClient, type PlainValue } from "@mdbase-dev/sdk";
import { MemoryReplica } from "@mdbase-dev/sdk/testing";
import type { CslItem } from "@mdbase-writer/core";
import { splitFrontmatter } from "@mdbase-writer/core/records";

import { WRITER_CLIENT } from "../connect/next-control.js";
import { NextBackend } from "./next.js";

const markdown = import.meta.glob("../../demo/**/*.md", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
const assets = import.meta.glob("../../demo/**/*.{svg,png,jpg}", { query: "?url", import: "default", eager: true }) as Record<string, string>;
const relative = (key: string) => key.replace(/^\.\.\/\.\.\/demo\//, "");

const values = (frontmatter: Record<string, unknown>) =>
Object.fromEntries(Object.entries(frontmatter).map(([key, value]) => [key, toValue(value as PlainValue)]));

function typesOf(frontmatter: Record<string, unknown>): string[] {
const type = frontmatter["type"];
return Array.isArray(type) ? type.filter((t): t is string => typeof t === "string") : typeof type === "string" ? [type] : [];
}

export interface NextDemoBackend extends NextBackend {
/** The in-memory replica, for poking at from the console. */
readonly replica: MemoryReplica;
/** Another client on the same replica, to make concurrent edits. */
otherClient(): Promise<MdbaseClient>;
}

export async function createNextDemoBackend(): Promise<NextDemoBackend> {
const replica = new MemoryReplica({ confirmDelayMs: 400 });
for (const [key, text] of Object.entries(markdown)) {
const { body, frontmatter } = splitFrontmatter(text);
replica.seed({ path: relative(key), types: typesOf(frontmatter), frontmatter: values(frontmatter), body });
}
const library = (await import("../../demo/library.json")).default as CslItem[];
for (const item of library) {
replica.seed({ path: `sources/${item.id}.md`, types: ["reader-source"], frontmatter: values({ type: "reader-source", csl: item }), body: "" });
}
const client = await connect({ app: WRITER_CLIENT, connector: replica.connector() });
await Promise.all(Object.entries(assets).map(async ([key, url]) => {
try {
const response = await fetch(url);
if (response.ok) await client.files.upload(relative(key), new Uint8Array(await response.arrayBuffer()));
} catch { /* A missing figure shows as a diagnostic, as in the other demos. */ }
}));
const backend = new NextBackend(client, { collectionName: "Demo collection (mdbase-next)", draftNamespace: "next-demo", ownsClient: true });
return Object.assign(backend, {
replica,
otherClient: () => connect({ app: { ...WRITER_CLIENT, name: "mdbase-writer-demo-peer" }, connector: replica.connector() }),
});
}
69 changes: 69 additions & 0 deletions apps/writer/src/backend/next-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// The mdbase-next SDK's 15 error codes, as Writer shows them. Each code has
// one recovery action (replica-client-api.md §9); Writer adds its own text.
import { isMdbaseError, type ErrorCode } from "@mdbase-dev/sdk";

import type { SessionProblem } from "./types.js";

/** What the person can do about a problem. */
export type NextRecovery = "fix" | "refresh" | "resolve" | "reconnect" | "repair" | "wait" | "free-space" | "upgrade" | "check" | "none" | "report";

export interface NextProblem extends SessionProblem {
readonly code: ErrorCode;
readonly message: string;
readonly reason?: string;
readonly recovery: NextRecovery;
/** A private collection with none of the person's devices online: a state, not an error. */
readonly waitingForDevice?: boolean;
}

const TEXT: Record<ErrorCode, { readonly message: string; readonly recovery: NextRecovery }> = {
invalid_request: { message: "Writer sent a request this collection could not accept.", recovery: "report" },
invalid_record: { message: "The collection's type rules do not accept this change.", recovery: "fix" },
not_found: { message: "This record no longer exists in the collection.", recovery: "refresh" },
conflict: { message: "This changed elsewhere at the same time.", recovery: "resolve" },
unauthenticated: { message: "Writer's access to this collection has ended. Connect again to continue.", recovery: "reconnect" },
forbidden: { message: "Writer is not allowed to make this change in this collection.", recovery: "reconnect" },
collection_invalid: { message: "The collection needs to be repaired before Writer can use it.", recovery: "repair" },
unavailable: { message: "The collection cannot be reached right now. Writer keeps trying.", recovery: "wait" },
rate_limited: { message: "The collection is busy. Writer will try again shortly.", recovery: "wait" },
quota_exceeded: { message: "The collection is out of space.", recovery: "free-space" },
too_large: { message: "This is too large for the collection to store.", recovery: "fix" },
upgrade_required: { message: "This version of Writer is too old for the collection. Reload to update it.", recovery: "upgrade" },
outcome_unknown: { message: "Writer could not confirm whether the last save went through. It will check again before saving more.", recovery: "check" },
cancelled: { message: "Cancelled.", recovery: "none" },
internal: { message: "Something went wrong in the collection.", recovery: "report" },
};

const REASONS: Readonly<Record<string, string>> = {
no_device_online: "Waiting for one of your devices to come online.",
path_taken: "A record already exists at that path.",
reconnecting: "Reconnecting to the collection.",
};

/** Writer's text for an SDK code and optional reason. */
export function nextProblem(code: ErrorCode, reason?: string): NextProblem {
const known = TEXT[code];
const message = (reason && REASONS[reason]) || known.message;
return {
code, message, recovery: known.recovery,
...(reason ? { reason } : {}),
...(code === "unavailable" && reason === "no_device_online" ? { waitingForDevice: true } : {}),
};
}

/** Any thrown value as a Writer problem: SDK errors by code, anything else as `internal`. */
export function problemFrom(error: unknown): NextProblem {
if (isMdbaseError(error)) return nextProblem(error.code, error.reason);
const problem = nextProblem("internal");
return error instanceof Error && error.message ? { ...problem, message: `${problem.message} (${error.message})` } : problem;
}

/** A problem from a rejected receipt's problem, which carries a code that may be newer than this SDK. */
export function problemFromWire(problem: { readonly code: string; readonly reason?: string } | undefined, fallback: ErrorCode = "internal"): NextProblem {
const code = problem && problem.code in TEXT ? problem.code as ErrorCode : fallback;
return nextProblem(code, problem?.reason);
}

/** A create may be retried at another path. */
export const isPathTaken = (problem: { readonly code: string; readonly reason?: string } | undefined) =>
problem?.code === "conflict" && (problem.reason === undefined || problem.reason === "path_taken");
Loading