@@ -1,6 +1,7 @@
|
||||
import type { WebFeature } from "@stc/contracts";
|
||||
import { learnerWeb } from "@stc/feature-learner/web";
|
||||
import { importWeb } from "../../../packages/feature-import/src/web";
|
||||
import { studioWeb } from "../../../packages/feature-studio/src/web";
|
||||
|
||||
// Phase 1 packages append one import line each and push into this list.
|
||||
export const webFeatures: WebFeature[] = [learnerWeb, importWeb];
|
||||
export const webFeatures: WebFeature[] = [learnerWeb, importWeb, studioWeb];
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Contract requests from WP-C (studio)
|
||||
|
||||
Each item says what is missing, what the studio does locally for now, and what Phase 2 should change.
|
||||
|
||||
## 1. Features that need the ServerContext (apps/server)
|
||||
|
||||
`startServer(config, features: Feature[])` builds the `ServerContext` internally, so a feature factory such as `studioFeature(ctx)` cannot be put into the feature list. The studio therefore does **not** touch `apps/server/src/main.ts`. Locally it runs through `packages/feature-studio/dev/serve.ts`, which mirrors `startServer`.
|
||||
|
||||
Proposal:
|
||||
- `startServer(config, features: (Feature | ((ctx: ServerContext) => Feature))[])`, and main.ts adds `studioFeature`.
|
||||
- An optional `stop?(): void | Promise<void>` on `Feature`, called from `stop()`. The studio uses it to stop its published-worktree watcher (an unref'd interval, so it never blocks exit).
|
||||
|
||||
## 2. Learners must read studio courses from the published worktree (config / main.ts)
|
||||
|
||||
Layout after the review (controller ruling on I4):
|
||||
- `<dataDir>/studio/repos/<id>.git`: studio bare repos. They contain no `stcourse.yaml`, so discovery never loads them.
|
||||
- `<dataDir>/worktrees/<id>/published`: the published branch, which is what learners read.
|
||||
- `<dataDir>/worktrees/<id>/drafts/<author>`: drafts. They sit at depth 3, so the depth-2 discovery never loads them.
|
||||
- `<dataDir>/courses/<id>`: the importer's plain folders (WP-B), untouched by the studio.
|
||||
|
||||
`createCourseRepository(config.courseDirs)` scans neither `<dataDir>/worktrees` nor `<dataDir>/courses`. The package exports `studioCourseDirs(config)`, which returns the configured dirs plus `<dataDir>/courses` and `<dataDir>/worktrees`. Tests and the dev entry use it.
|
||||
|
||||
Proposal: main.ts calls `createCourseRepository(studioCourseDirs(config))`, or `loadConfig` appends both dirs.
|
||||
|
||||
Imported courses (`source.kind` other than `authored`, with their root under `<dataDir>/courses`) are read-only in the studio for v1, with the message "Imported course: read-only in the studio for now". There is a `TODO(studio)` in `feature.ts` (`readOnlyMessage`) for converting one into a studio-managed repo. The studio refuses to create a course whose id already exists as `<dataDir>/courses/<id>`. WP-B should likewise refuse an import id that `<dataDir>/studio/repos/<id>.git` already uses.
|
||||
|
||||
## 3. Live preview: swap in the learner `LessonView`
|
||||
|
||||
The studio preview sits behind an adapter in `@stc/feature-studio/web`:
|
||||
|
||||
```ts
|
||||
export interface LessonPreviewProps {
|
||||
courseId: string;
|
||||
markdown: string;
|
||||
lesson?: { id: string; title: string; type: string; anchor?: string } | null;
|
||||
resolveUrl: (rel: string) => string; // course-relative path -> URL of the draft file
|
||||
}
|
||||
export function setLessonPreview(c: React.ComponentType<LessonPreviewProps>): void;
|
||||
```
|
||||
|
||||
v1 uses a small, safe built-in Markdown renderer. Proposal: `@stc/feature-learner` exports a `LessonView` that can render from a Markdown string plus a URL resolver, without fetching the published course. Phase 2 then calls `setLessonPreview(adapter)` in `packages/feature-studio/src/web.ts`, so drafts are previewed with the real renderer.
|
||||
|
||||
## 4. Web entry points (tsconfig paths)
|
||||
|
||||
Phase 1 packages have server code (`Bun.$`, `node:fs`) that must not enter the browser bundle, so the studio has a separate browser entry, `src/web.ts`. The `@stc/*` → `packages/*/src` path maps `@stc/feature-studio/web` to a directory that does not exist, and the workspace has no `node_modules/@stc` links. For now, `apps/web/src/features.ts` imports it with a relative path.
|
||||
|
||||
Proposal: add `"@stc/*/web": ["packages/*/src/web.ts"]` to the root tsconfig `paths`, and switch the line to `import { studioWeb } from "@stc/feature-studio/web"`.
|
||||
|
||||
## 5. Role-aware navigation (contracts/web.ts)
|
||||
|
||||
`WebRoute.nav` cannot be limited by role, so the header "Studio" link shows for everyone. The page itself tells learners and anonymous users that the studio is for authors, and the API returns 401/403.
|
||||
|
||||
Proposal: add `nav.roles?: Role[]`, and have the shell show the link only to matching users. This needs the learner package's session in the web shell.
|
||||
|
||||
## 6. Draft API for WP-D (AI)
|
||||
|
||||
Exported from `@stc/feature-studio`:
|
||||
|
||||
```ts
|
||||
export interface DraftWriter {
|
||||
writeDraftFiles(courseId: string, authorId: string, files: { path: string; content: string }[], message: string): Promise<{ branch: string; commit: string }>;
|
||||
}
|
||||
export function createDraftWriter(ctx: { config: Pick<AppConfig, "dataDir"> }, opts?: {
|
||||
identity?: (authorId: string) => Promise<{ name: string; email: string } | null> | { name: string; email: string } | null;
|
||||
}): DraftWriter;
|
||||
```
|
||||
|
||||
- It writes and commits on `draft/<authorId>` and never publishes.
|
||||
- Without `identity`, commits are authored as `<authorId> <authorId@users.stcourses.invalid>`. WP-D should pass the signed-in user's name and email.
|
||||
- It throws for courses not managed by the studio, and for paths that are absolute, contain `..` or touch `.git`.
|
||||
- `studioFeature(ctx).draftWriter` is the same API, already bound to `ctx`.
|
||||
|
||||
## 7. Authors and roles (decision to confirm)
|
||||
|
||||
Studio access goes to:
|
||||
- `owner` and `author` roles: all studio courses, and they can create courses;
|
||||
- users with a `course_authors` row: that course only.
|
||||
|
||||
Creating a course inserts the creator into `course_authors`. No new tables are added, so `migrationsDirs` is `[]`.
|
||||
|
||||
## 8. External pushes straight into the bare repo (no action needed)
|
||||
|
||||
The platform reloads when the published worktree's HEAD changes: an external commit in that worktree, a studio publish, or a Sync. A direct `git push` into `<dataDir>/courses/<id>` is refused by git itself ("branch is currently checked out", checked with git 2.47), so the published files cannot silently fall behind `main`. Outside tools should commit in the published worktree, or push to a configured remote that the author then Syncs.
|
||||
|
||||
## 9. Core media route serves SVG/HTML inline (core, Phase 2)
|
||||
|
||||
The studio's draft `raw` route now sends `Content-Security-Policy: …; sandbox` and `X-Content-Type-Options: nosniff`. The learner-facing `/api/media/:courseId/*` in `packages/core` serves author-supplied SVG inline from the app's origin and sets neither header.
|
||||
|
||||
Proposal: add the same two headers in `serveMedia`.
|
||||
@@ -0,0 +1,77 @@
|
||||
# STCourses WP-C Studio Implementation Plan
|
||||
|
||||
> **For agentic workers:** executed inline by the WP-C agent (superpowers:executing-plans). Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Browser studio for studio-managed courses: create a course, edit the manifest, lessons (Markdown with live preview), deck links, attachments and exercises in a per-author draft worktree; every save is a commit by the author; publish merges the draft into the published branch that learners read; Sync with a remote only on request; conflicts are shown and resolved by the author, never automatically.
|
||||
|
||||
**Architecture:**
|
||||
- `packages/feature-studio/src/server/git.ts`: a thin `git` CLI wrapper over `Bun.$` (explicit identity, no hooks, no signing) plus a per-course async mutex.
|
||||
- `packages/feature-studio/src/server/store.ts`: `StudioStore`, the git model of spec section 12:
|
||||
- `<dataDir>/courses/<id>`: a **bare** repo (it holds no `stcourse.yaml`, so course discovery never loads it);
|
||||
- `<dataDir>/worktrees/<id>/published`: worktree on the published branch (`main`); the only tree learners read;
|
||||
- `<dataDir>/worktrees/<id>/drafts/<author>`: worktree on `draft/<author>` (depth 3 under `worktrees/`, so discovery, which stops at depth 2, never loads drafts).
|
||||
- Publish = `git merge --no-ff draft/<author>` inside the published worktree. A conflicting merge is aborted at once, so the published tree is never left half-merged, and the conflicting paths are returned. The author then runs "Update draft" (merge of `main`, or `origin/main` for Sync, into the draft worktree), resolves each file in the studio, and publishes again.
|
||||
- Save uses optimistic concurrency: each file carries the hash of the content the editor loaded; a mismatch with the file on disk (an external edit) gives 409 with both versions. Nothing is overwritten.
|
||||
- `watch.ts`: polls the published HEAD of each managed course (default every 3 s, unref'd timer) and calls `ctx.courses.reload(id)` when it changed (external commit, pull, publish).
|
||||
- `feature.ts`: `studioFeature(ctx)` with `/api/studio/*` routes. Access: owner, `author` role, or a `course_authors` row for that course. Courses not managed by the studio (overlays, fixture/imported folders) are listed read-only: "This course is managed outside STCourses".
|
||||
- `draft-writer.ts`: `createDraftWriter(ctx)` for WP-D.
|
||||
- Web (`src/web/*`): `/studio` (course list, New course) and `/studio/:courseId` (outline sidebar, editors, status bar with Publish / Update draft / Sync and the conflict view). Work look from the mockup. The lesson preview sits behind a `LessonPreview` adapter; v1 is a small safe Markdown renderer; Phase 2 swaps in the learner `LessonView` (contract request).
|
||||
|
||||
**Tech Stack:** Bun 1.3, TypeScript strict, git CLI 2.4x via `Bun.$`, React 19 + React Router 6, `@stc/course-format` for validation, `@stc/ui` primitives.
|
||||
|
||||
**Spec:** sections 5, 9.1, 12 (git model), 13. Brief: `.superpowers/sdd/2026-10-30-stcourses/phase-1-brief.md` (WP-C).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
Inherits every item of the Global Constraints in `docs/superpowers/plans/2026-10-30-stcourses.md`. Additionally:
|
||||
- Never write into a course whose root is outside `<dataDir>` (overlays) — the studio refuses writes for non-managed courses.
|
||||
- Paths written through the studio must be relative, stay inside the worktree, and never touch `.git`.
|
||||
- No new DB tables (authors use the existing `course_authors`); `migrationsDirs` is exported empty.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. A conflicting external commit to `main` is detected at publish and shown; the published tree keeps the external content (test: acceptance).
|
||||
2. Drafts never reach learners: before publish the `CourseRepository` still serves the published content (test: acceptance).
|
||||
3. An external edit to a draft file between load and save gives 409, not an overwrite (test: store).
|
||||
4. Paths with spaces and non-ASCII characters save, commit and publish (test: store).
|
||||
5. Path traversal (`..`, absolute, `.git/…`) is rejected (test: store).
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: git wrapper and StudioStore (create, drafts, save)
|
||||
|
||||
**Files:** Create `packages/feature-studio/src/server/{git.ts,store.ts,paths.ts}`; Test `packages/feature-studio/test/store.test.ts`.
|
||||
|
||||
**Interfaces (Produces):** `createStudioStore(dataDir): StudioStore` with `createCourse`, `isManaged`, `listManaged`, `ensureDraft`, `readFile`, `listFiles`, `saveFiles`, `status`.
|
||||
|
||||
- [ ] Step 1: failing tests: create gives bare repo + published worktree with a valid manifest; draft worktree on `draft/<author>`; save commits with the author's name/email; stale base hash gives a conflict; traversal rejected; non-ASCII path works.
|
||||
- [ ] Step 2: run, FAIL. Step 3: implement. Step 4: PASS. Step 5: commit.
|
||||
|
||||
### Task 2: publish, update draft, conflict resolution, sync
|
||||
|
||||
**Files:** extend `store.ts`; Test `packages/feature-studio/test/publish.test.ts`.
|
||||
|
||||
- [ ] Step 1: failing tests: publish merges; external conflicting commit gives `{ok:false, conflicts}` and leaves `main` untouched; dirty published tree refuses publish; update draft shows ours/theirs; resolve + publish succeeds; abort; Sync with a local bare remote (pull fast-forward, push, conflict reported); no remote gives an error.
|
||||
- [ ] Steps 2–5 as above.
|
||||
|
||||
### Task 3: watcher and draft writer
|
||||
|
||||
**Files:** `src/server/{watch.ts,draft-writer.ts}`; Test `test/watch.test.ts`, `test/draft-writer.test.ts`.
|
||||
|
||||
- [ ] Failing tests: external commit triggers `courses.reload`; `writeDraftFiles` commits on `draft/<author>` and the published tree is unchanged. Implement, pass, commit.
|
||||
|
||||
### Task 4: HTTP feature and acceptance
|
||||
|
||||
**Files:** `src/server/feature.ts`, `src/index.ts`; Test `test/feature.test.ts`, `test/acceptance.test.ts`.
|
||||
|
||||
- [ ] Failing tests: 401/403 for anonymous and learners; overlay course read-only; create → edit lesson → publish → learner sees it via `CourseRepository` and `/api/media`; conflicting external commit shown, not overwritten; manifest validation errors returned. Implement, pass, commit.
|
||||
|
||||
### Task 5: Studio web UI
|
||||
|
||||
**Files:** `src/web/{api.ts,markdown.ts,preview.tsx,StudioHome.tsx,CourseStudio.tsx,editors/*.tsx,studio.css}`, `src/web.ts`; Test `test/markdown.test.ts`.
|
||||
|
||||
- [ ] Failing test for the Markdown renderer (escaping, headings, fences, lists, links without `javascript:`). Implement UI. Commit.
|
||||
|
||||
### Task 6: wiring, dev server, contract requests, verification
|
||||
|
||||
- [ ] Append to `apps/web/src/features.ts`; dev entry `packages/feature-studio/dev/serve.ts`; `docs/contract-requests/studio.md`; run `bun test`, typecheck, Postgres run; headless Chrome screenshots of `/studio` and `/studio/:id`; report.
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Dev server for the studio until Phase 2 lets apps/server compose ctx-dependent features
|
||||
* (docs/contract-requests/studio.md). Mirrors startServer, adds the studio feature and a dev-only user.
|
||||
*
|
||||
* STC_PORT=3463 STC_DATA_DIR=/tmp/stc-studio-data STC_DEV_USER=author bun packages/feature-studio/dev/serve.ts
|
||||
*
|
||||
* STC_DEV_USER: "author" | "owner" | "learner" | unset (anonymous). Never use this entry in production.
|
||||
*/
|
||||
import type { AuthUser, ServerContext } from "@stc/contracts";
|
||||
import { createApp, createJobQueue, loadConfig } from "@stc/core";
|
||||
import { createCourseRepository } from "@stc/course-format";
|
||||
import { connect, migrate } from "@stc/db";
|
||||
import indexHtml from "../../../apps/web/index.html";
|
||||
import { studioCourseDirs, studioFeature } from "../src";
|
||||
|
||||
const config = loadConfig();
|
||||
if (process.env.NODE_ENV === "production") throw new Error("dev/serve.ts is for development only");
|
||||
const role = process.env.STC_DEV_USER as AuthUser["roles"][number] | undefined;
|
||||
const devUser: AuthUser | null = role ? { id: `dev-${role}`, name: `Dev ${role[0]!.toUpperCase()}${role.slice(1)}`, email: `${role}@dev.invalid`, roles: [role], groups: [] } : null;
|
||||
|
||||
const db = await connect(config.databaseUrl);
|
||||
await migrate(db);
|
||||
const courses = await createCourseRepository(studioCourseDirs(config));
|
||||
const jobs = createJobQueue(db, {});
|
||||
const ctx: ServerContext = { config, db, courses, jobs, auth: { userFromRequest: async () => devUser } };
|
||||
const studio = studioFeature(ctx);
|
||||
const app = createApp(ctx, [studio]);
|
||||
jobs.start();
|
||||
const server = Bun.serve({
|
||||
port: config.port,
|
||||
routes: { ...app.routes, "/api/*": (req) => app.fetch(req), "/*": indexHtml },
|
||||
fetch: (req) => app.fetch(req),
|
||||
development: { hmr: false, console: true },
|
||||
});
|
||||
console.log(`STCourses studio dev server on ${server.url} as ${devUser ? devUser.name : "anonymous"}`);
|
||||
@@ -3,5 +3,9 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "AGPL-3.0-only"
|
||||
"license": "AGPL-3.0-only",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./web": "./src/web.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Server entry of @stc/feature-studio. The browser imports "@stc/feature-studio/web" instead (no server code).
|
||||
export { studioFeature, OUTSIDE_MESSAGE, IMPORTED_MESSAGE, uploadMaxBytes, UPLOAD_EXTENSIONS, type StudioFeature, type StudioFeatureOptions } from "./server/feature";
|
||||
export { createDraftWriter, type DraftWriter, type DraftWriterOptions } from "./server/draft-writer";
|
||||
export { createStudioStore, contentHash, isAllowedRemote, ownerRequiredFor, type StudioStore, type Identity, type DraftStatus, type PublishResult, type SyncResult } from "./server/store";
|
||||
export { createPublishedWatcher, type PublishedWatcher } from "./server/watch";
|
||||
export { studioCourseDirs, studioWorktreesDir, studioReposDir } from "./server/paths";
|
||||
export { studioWeb } from "./web";
|
||||
|
||||
/** The studio adds no tables: authors use Phase 0's `course_authors`. */
|
||||
export const migrationsDirs: string[] = [];
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { AppConfig } from "@stc/contracts";
|
||||
import { createStudioStore, StudioError, safeAuthor, type Identity } from "./store";
|
||||
|
||||
/** Programmatic draft API for other packages (WP-D's studio generator). Writes only to `draft/<authorId>`, never publishes. */
|
||||
export interface DraftWriter {
|
||||
writeDraftFiles(
|
||||
courseId: string,
|
||||
authorId: string,
|
||||
files: { path: string; content: string }[],
|
||||
message: string,
|
||||
): Promise<{ branch: string; commit: string }>;
|
||||
}
|
||||
|
||||
export interface DraftWriterOptions {
|
||||
/** Commit author for `authorId`. Without it (or when it returns null) commits use `<authorId> <authorId@users.stcourses.invalid>`. */
|
||||
identity?: (authorId: string) => Promise<Identity | null> | Identity | null;
|
||||
}
|
||||
|
||||
export function createDraftWriter(ctx: { config: Pick<AppConfig, "dataDir"> }, opts: DraftWriterOptions = {}): DraftWriter {
|
||||
const store = createStudioStore({ dataDir: ctx.config.dataDir });
|
||||
return {
|
||||
async writeDraftFiles(courseId, authorId, files, message) {
|
||||
if (!store.isManaged(courseId)) throw new StudioError(`course ${courseId} is not managed by the studio`, 404);
|
||||
const id = (await opts.identity?.(authorId)) ?? { name: authorId, email: `${safeAuthor(authorId)}@users.stcourses.invalid` };
|
||||
const r = await store.saveFiles(courseId, { ...id, id: authorId }, files.map((f) => ({ path: f.path, content: f.content })), message);
|
||||
if (!r.ok) throw new StudioError("draft write conflict", 409); // unreachable: no base hashes are given
|
||||
const st = await store.status(courseId, authorId);
|
||||
return { branch: st.branch, commit: r.commit ?? st.draftHead };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { basename, extname, resolve, sep } from "node:path";
|
||||
import type { AuthUser, Feature, RequestContext, RouteHandler, ServerContext } from "@stc/contracts";
|
||||
import { serveMedia } from "@stc/core";
|
||||
import { ManifestSchema, parseManifest, resolveInside, type LoadedCourse } from "@stc/course-format";
|
||||
import { aclRepo } from "@stc/db";
|
||||
import { createDraftWriter, type DraftWriter } from "./draft-writer";
|
||||
import { studioWorktreesDir } from "./paths";
|
||||
import { checkRelPath, createStudioStore, MANIFEST, ownerRequiredFor, StudioError, type FileWrite, type StudioStore } from "./store";
|
||||
import { createPublishedWatcher, type PublishedWatcher } from "./watch";
|
||||
|
||||
export const OUTSIDE_MESSAGE = "This course is managed outside STCourses";
|
||||
export const IMPORTED_MESSAGE = "Imported course: read-only in the studio for now";
|
||||
|
||||
/** Upload cap in bytes: STC_UPLOAD_MAX_MB, default 100 (review I2). */
|
||||
export function uploadMaxBytes(env: Record<string, string | undefined> = process.env): number {
|
||||
const mb = Number(env.STC_UPLOAD_MAX_MB ?? "100");
|
||||
return (Number.isFinite(mb) && mb > 0 ? mb : 100) * 1024 * 1024;
|
||||
}
|
||||
|
||||
/** Server-side allowlist by extension for uploads: text, documents, images, media, archives and code. */
|
||||
export const UPLOAD_EXTENSIONS = new Set([
|
||||
"md", "markdown", "txt", "pdf", "png", "jpg", "jpeg", "gif", "svg", "webp", "mp4", "webm", "mp3", "vtt", "srt", "zip",
|
||||
"js", "mjs", "cjs", "jsx", "ts", "tsx", "json", "yaml", "yml", "py", "sh", "css", "html", "csv", "toml", "sql",
|
||||
"scala", "sc", "fs", "fsx", "fsproj", "rs", "go", "java", "kt", "c", "h", "cpp", "hpp", "cs", "rb", "php", "swift", "hs", "ex", "exs", "dockerfile",
|
||||
]);
|
||||
|
||||
/** Reads at most `max` bytes of the body; null when it is larger (checked while streaming, not after buffering). */
|
||||
async function readCapped(req: Request, max: number): Promise<Uint8Array<ArrayBuffer> | null> {
|
||||
const reader = req.body?.getReader();
|
||||
if (!reader) return new Uint8Array();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > max) { await reader.cancel(); return null; }
|
||||
chunks.push(value);
|
||||
}
|
||||
const out = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const c of chunks) { out.set(c, off); off += c.byteLength; }
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface StudioFeature extends Feature {
|
||||
store: StudioStore;
|
||||
watcher: PublishedWatcher;
|
||||
draftWriter: DraftWriter;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export interface StudioFeatureOptions {
|
||||
/** Poll the published worktrees for external commits (default true). */
|
||||
watch?: boolean;
|
||||
watchIntervalMs?: number;
|
||||
}
|
||||
|
||||
const json = (body: unknown, status = 200) => Response.json(body, { status });
|
||||
const fail = (status: number, error: string) => json({ error }, status);
|
||||
|
||||
async function body<T>(req: Request): Promise<T> {
|
||||
try {
|
||||
return (await req.json()) as T;
|
||||
} catch {
|
||||
throw new StudioError("request body must be JSON");
|
||||
}
|
||||
}
|
||||
|
||||
function manifestToYaml(m: unknown): string {
|
||||
return Bun.YAML.stringify(m, null, 2) + "\n";
|
||||
}
|
||||
|
||||
export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {}): StudioFeature {
|
||||
const store = createStudioStore({ dataDir: ctx.config.dataDir });
|
||||
const watcher = createPublishedWatcher(store, ctx.courses, { intervalMs: opts.watchIntervalMs });
|
||||
const acl = aclRepo(ctx.db);
|
||||
const wtDir = resolve(studioWorktreesDir(ctx.config.dataDir));
|
||||
const draftWriter = createDraftWriter(ctx);
|
||||
|
||||
const hasRole = (u: AuthUser) => u.roles.includes("owner") || u.roles.includes("author");
|
||||
const authoredCourses = async (u: AuthUser): Promise<string[]> =>
|
||||
(await ctx.db.sql`SELECT course_id FROM course_authors WHERE user_id = ${u.id}`).map((r: { course_id: string }) => r.course_id);
|
||||
const canEdit = async (u: AuthUser, courseId: string) => hasRole(u) || (await acl.isAuthor(courseId, u.id));
|
||||
const isStudioCourse = (c: LoadedCourse) => resolve(c.manifestPath).startsWith(wtDir + sep);
|
||||
const who = (u: AuthUser) => ({ id: u.id, name: u.name || u.email || u.id, email: u.email || `${u.id}@users.stcourses.invalid`, owner: u.roles.includes("owner") });
|
||||
const importedRoot = (() => { const d = resolve(ctx.config.dataDir, "courses"); return existsSync(d) ? realpathSync(d) : d; })();
|
||||
/** Why a course that is not studio-managed is read-only here. */
|
||||
const readOnlyMessage = (c: LoadedCourse): string => {
|
||||
const kind = c.manifest?.source?.kind;
|
||||
const underImports = c.root === importedRoot || c.root.startsWith(importedRoot + sep);
|
||||
if (kind && kind !== "authored" && underImports) {
|
||||
// TODO(studio): convert imported course to a studio-managed repo (git init into <dataDir>/studio/repos/<id>.git
|
||||
// plus a published worktree), so authors can edit it and the owner-only visibility rule applies on publish.
|
||||
return IMPORTED_MESSAGE;
|
||||
}
|
||||
return OUTSIDE_MESSAGE;
|
||||
};
|
||||
|
||||
const afterPublishedChange = async (courseId: string) => {
|
||||
await ctx.courses.reload(courseId);
|
||||
watcher.note(courseId, await store.publishedHead(courseId));
|
||||
};
|
||||
|
||||
/** Wraps a handler: signed-in author/owner (or per-course author), error mapping. */
|
||||
const guard = (h: (req: Request, rc: RequestContext, user: AuthUser) => Promise<Response>): RouteHandler => async (req, rc) => {
|
||||
const user = rc.user;
|
||||
if (!user) return fail(401, "sign in to use the studio");
|
||||
try {
|
||||
if (!hasRole(user) && (await authoredCourses(user)).length === 0) return fail(403, "the studio is for authors");
|
||||
return await h(req, rc, user);
|
||||
} catch (e) {
|
||||
if (e instanceof StudioError) return fail(e.status, e.message);
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (/^invalid path/.test(msg)) return fail(400, msg);
|
||||
console.error("[studio]", e);
|
||||
return fail(500, msg);
|
||||
}
|
||||
};
|
||||
|
||||
/** For per-course routes: the course must be studio-managed and editable by the user. */
|
||||
const course = (h: (req: Request, rc: RequestContext, user: AuthUser, courseId: string) => Promise<Response>) =>
|
||||
guard(async (req, rc, user) => {
|
||||
const courseId = rc.params.courseId ?? "";
|
||||
if (!(await canEdit(user, courseId))) return fail(403, "you are not an author of this course");
|
||||
if (!store.isManaged(courseId)) {
|
||||
const c = ctx.courses.get(courseId);
|
||||
return c ? fail(403, readOnlyMessage(c)) : fail(404, "no such course");
|
||||
}
|
||||
return h(req, rc, user, courseId);
|
||||
});
|
||||
|
||||
async function courseState(courseId: string, user: AuthUser) {
|
||||
const status = await store.status(courseId, user.id);
|
||||
const m = await store.readFile(courseId, user.id, MANIFEST);
|
||||
let manifestRaw: unknown = null;
|
||||
let manifestErrors: string[] = [];
|
||||
if (m) {
|
||||
try { manifestRaw = Bun.YAML.parse(m.content); } catch { /* reported by parseManifest */ }
|
||||
const parsed = parseManifest(m.content);
|
||||
manifestErrors = parsed.ok ? [] : parsed.errors;
|
||||
} else {
|
||||
manifestErrors = [`${MANIFEST} is missing`];
|
||||
}
|
||||
return {
|
||||
readOnly: false,
|
||||
course: { id: courseId, title: (manifestRaw as { title?: string } | null)?.title ?? courseId, managed: true },
|
||||
status,
|
||||
manifestText: m?.content ?? "",
|
||||
manifestHash: m?.hash ?? null,
|
||||
manifestRaw,
|
||||
manifestErrors,
|
||||
diagnostics: status.merging ? [] : await store.validateDraft(courseId, user.id),
|
||||
files: await store.listFiles(courseId, user.id),
|
||||
};
|
||||
}
|
||||
|
||||
const routes: Feature["routes"] = {
|
||||
"/api/studio/courses": {
|
||||
GET: guard(async (_req, _rc, user) => {
|
||||
const mine = new Set(await authoredCourses(user));
|
||||
const all = hasRole(user);
|
||||
const managed = await store.listManaged();
|
||||
const courses: unknown[] = [];
|
||||
for (const id of managed) {
|
||||
if (!all && !mine.has(id)) continue;
|
||||
const loaded = ctx.courses.get(id);
|
||||
const st = await store.summary(id, user.id).catch(() => null); // read-only: no worktree is created here
|
||||
courses.push({
|
||||
id, managed: true,
|
||||
title: loaded?.manifest?.title ?? id,
|
||||
pitch: loaded?.manifest?.pitch ?? null,
|
||||
accent: loaded?.manifest?.accent ?? null,
|
||||
publishedStatus: loaded?.status ?? "error",
|
||||
ahead: st?.ahead ?? 0, behind: st?.behind ?? 0, merging: st?.merging ?? false, remote: st?.remote ?? null,
|
||||
});
|
||||
}
|
||||
if (all) {
|
||||
for (const c of ctx.courses.list()) {
|
||||
if (isStudioCourse(c) || managed.includes(c.id)) continue;
|
||||
courses.push({ id: c.id, managed: false, title: c.manifest?.title ?? c.id, pitch: c.manifest?.pitch ?? null, accent: c.manifest?.accent ?? null, publishedStatus: c.status, message: readOnlyMessage(c) });
|
||||
}
|
||||
}
|
||||
return json({ courses, canCreate: all });
|
||||
}),
|
||||
POST: guard(async (req, _rc, user) => {
|
||||
if (!hasRole(user)) return fail(403, "only authors can create courses");
|
||||
const b = await body<{ id?: unknown; title?: unknown; pitch?: unknown }>(req);
|
||||
if (typeof b.id !== "string" || typeof b.title !== "string" || !b.title.trim()) return fail(400, "id and title are required");
|
||||
if (ctx.courses.get(b.id) || store.isManaged(b.id)) return fail(409, `course "${b.id}" already exists`);
|
||||
await store.createCourse({ id: b.id, title: b.title, pitch: typeof b.pitch === "string" ? b.pitch : undefined }, who(user));
|
||||
await ctx.db.sql`INSERT INTO course_authors (course_id, user_id) VALUES (${b.id}, ${user.id})`;
|
||||
await afterPublishedChange(b.id);
|
||||
return json({ id: b.id }, 201);
|
||||
}),
|
||||
},
|
||||
|
||||
"/api/studio/courses/:courseId": {
|
||||
GET: guard(async (_req, rc, user) => {
|
||||
const courseId = rc.params.courseId ?? "";
|
||||
if (!(await canEdit(user, courseId))) return fail(403, "you are not an author of this course");
|
||||
if (!store.isManaged(courseId)) {
|
||||
const c = ctx.courses.get(courseId);
|
||||
if (!c) return fail(404, "no such course");
|
||||
return json({ readOnly: true, message: readOnlyMessage(c), course: { id: c.id, title: c.manifest?.title ?? c.id, managed: false, root: c.root }, diagnostics: c.diagnostics });
|
||||
}
|
||||
return json(await courseState(courseId, user));
|
||||
}),
|
||||
},
|
||||
|
||||
"/api/studio/courses/:courseId/file": {
|
||||
GET: course(async (_req, rc, user, courseId) => {
|
||||
const path = rc.url.searchParams.get("path") ?? "";
|
||||
const f = await store.readFile(courseId, user.id, path);
|
||||
return f ? json({ path, ...f }) : fail(404, "no such file");
|
||||
}),
|
||||
},
|
||||
|
||||
"/api/studio/courses/:courseId/files": {
|
||||
PUT: course(async (req, _rc, user, courseId) => {
|
||||
const b = await body<{ message?: string; files?: FileWrite[]; manifest?: unknown; manifestBaseHash?: string | null }>(req);
|
||||
const files: FileWrite[] = Array.isArray(b.files) ? b.files.map((f) => ({ path: String(f.path), content: f.delete ? undefined : String(f.content ?? ""), baseHash: f.baseHash, delete: !!f.delete })) : [];
|
||||
if (b.manifest !== undefined) {
|
||||
files.unshift({ path: MANIFEST, content: manifestToYaml(b.manifest), baseHash: b.manifestBaseHash });
|
||||
}
|
||||
const mf = files.find((f) => checkRelPath(f.path) === MANIFEST);
|
||||
if (mf && typeof mf.content === "string" && !user.roles.includes("owner")) {
|
||||
// Early feedback only; publish, sync and resolve enforce the same rule on the merged result.
|
||||
const needOwner = ownerRequiredFor(await store.publishedManifest(courseId), mf.content);
|
||||
if (needOwner) return fail(403, needOwner);
|
||||
}
|
||||
const r = await store.saveFiles(courseId, who(user), files, b.message ?? "");
|
||||
if (!r.ok) return json({ ok: false, reason: "conflict", conflicts: r.conflicts }, 409);
|
||||
return json({ ok: true, commit: r.commit });
|
||||
}),
|
||||
},
|
||||
|
||||
"/api/studio/courses/:courseId/validate": {
|
||||
POST: course(async (req) => {
|
||||
const b = await body<{ manifest?: unknown; manifestText?: string }>(req);
|
||||
if (typeof b.manifestText === "string") {
|
||||
const r = parseManifest(b.manifestText);
|
||||
return json({ errors: r.ok ? [] : r.errors });
|
||||
}
|
||||
const r = ManifestSchema.safeParse(b.manifest);
|
||||
return json({ errors: r.success ? [] : r.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`) });
|
||||
}),
|
||||
},
|
||||
|
||||
"/api/studio/courses/:courseId/upload": {
|
||||
POST: course(async (req, _rc, user, courseId) => {
|
||||
const max = uploadMaxBytes();
|
||||
const declared = Number(req.headers.get("content-length") ?? "0");
|
||||
if (declared > max) return fail(413, `file too large (limit ${Math.round(max / 1048576)} MB)`);
|
||||
const bytes = await readCapped(req, max);
|
||||
if (!bytes) return fail(413, `file too large (limit ${Math.round(max / 1048576)} MB)`);
|
||||
let form: FormData;
|
||||
try { form = await new Response(bytes, { headers: { "content-type": req.headers.get("content-type") ?? "" } }).formData(); } catch { return fail(400, "expected multipart form data"); }
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof Blob)) return fail(400, "file is required");
|
||||
const explicit = form.get("path");
|
||||
const dir = String(form.get("dir") ?? "").replace(/^\/+|\/+$/g, "");
|
||||
const name = basename((file as File).name || "upload.bin");
|
||||
const path = checkRelPath(typeof explicit === "string" && explicit ? explicit : dir ? `${dir}/${name}` : name);
|
||||
if (path === MANIFEST) return fail(400, "edit stcourse.yaml in the manifest editor, not by upload");
|
||||
const ext = (extname(path).slice(1) || basename(path)).toLowerCase();
|
||||
if (!UPLOAD_EXTENSIONS.has(ext)) return fail(415, `file type .${ext} is not accepted`);
|
||||
const overwrite = String(form.get("overwrite") ?? "") === "true";
|
||||
const message = String(form.get("message") ?? "") || `Upload ${path}`;
|
||||
// baseHash null = the file must not exist yet; replacing needs an explicit overwrite=true
|
||||
const r = await store.saveFiles(courseId, who(user), [{ path, content: new Uint8Array(await file.arrayBuffer()), ...(overwrite ? {} : { baseHash: null }) }], message);
|
||||
if (!r.ok) return json({ ok: false, reason: "exists", error: `${path} already exists; upload again with overwrite to replace it`, conflicts: r.conflicts }, 409);
|
||||
return json({ ok: true, path, commit: r.commit });
|
||||
}),
|
||||
},
|
||||
|
||||
"/api/studio/courses/:courseId/raw/*": {
|
||||
GET: course(async (req, rc, user, courseId) => {
|
||||
const m = /^\/api\/studio\/courses\/[^/]+\/raw\/(.*)$/s.exec(rc.url.pathname);
|
||||
let rel: string;
|
||||
try { rel = decodeURIComponent(m?.[1] ?? ""); } catch { return fail(404, "not found"); }
|
||||
const dir = await store.draftDir(courseId, user.id);
|
||||
try { checkRelPath(rel); } catch { return fail(404, "not found"); }
|
||||
const abs = resolveInside(dir, rel);
|
||||
if (!abs) return fail(404, "not found");
|
||||
const res = await serveMedia(req, abs, { download: rc.url.searchParams.has("download") });
|
||||
// Draft files are author content served from the app's origin: never let them run script (SVG, HTML).
|
||||
res.headers.set("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self'; sandbox");
|
||||
res.headers.set("X-Content-Type-Options", "nosniff");
|
||||
return res;
|
||||
}),
|
||||
},
|
||||
|
||||
"/api/studio/courses/:courseId/publish": {
|
||||
POST: course(async (_req, _rc, user, courseId) => {
|
||||
const r = await store.publish(courseId, who(user));
|
||||
if (r.ok) {
|
||||
await afterPublishedChange(courseId);
|
||||
return json(r);
|
||||
}
|
||||
return json(r, r.reason === "invalid" ? 422 : r.reason === "forbidden" ? 403 : 409);
|
||||
}),
|
||||
},
|
||||
|
||||
"/api/studio/courses/:courseId/update-draft": {
|
||||
POST: course(async (req, _rc, user, courseId) => {
|
||||
const b = req.headers.get("content-type")?.includes("json") ? await body<{ from?: string }>(req) : {};
|
||||
const r = await store.updateDraft(courseId, who(user), b.from === "remote" ? "remote" : "published");
|
||||
return json(r, r.ok ? 200 : r.reason === "no-remote" ? 400 : 409);
|
||||
}),
|
||||
},
|
||||
|
||||
"/api/studio/courses/:courseId/conflicts": {
|
||||
GET: course(async (_req, _rc, user, courseId) => json({ files: await store.conflicts(courseId, user.id) })),
|
||||
},
|
||||
|
||||
"/api/studio/courses/:courseId/resolve": {
|
||||
POST: course(async (req, _rc, user, courseId) => {
|
||||
const b = await body<{ files?: { path: string; content: string | null }[] }>(req);
|
||||
if (!Array.isArray(b.files) || !b.files.length) return fail(400, "files are required");
|
||||
return json(await store.resolve(courseId, who(user), b.files.map((f) => ({ path: String(f.path), content: f.content === null ? null : String(f.content) }))));
|
||||
}),
|
||||
},
|
||||
|
||||
"/api/studio/courses/:courseId/abort": {
|
||||
POST: course(async (_req, _rc, user, courseId) => {
|
||||
await store.abortMerge(courseId, user.id);
|
||||
return json({ ok: true });
|
||||
}),
|
||||
},
|
||||
|
||||
"/api/studio/courses/:courseId/remote": {
|
||||
PUT: course(async (req, _rc, _user, courseId) => {
|
||||
const b = await body<{ url?: string | null }>(req);
|
||||
await store.setRemote(courseId, b.url ? String(b.url) : null);
|
||||
return json({ ok: true, remote: await store.remote(courseId) });
|
||||
}),
|
||||
},
|
||||
|
||||
"/api/studio/courses/:courseId/sync": {
|
||||
POST: course(async (_req, _rc, user, courseId) => {
|
||||
const r = await store.sync(courseId, who(user));
|
||||
if (r.ok) {
|
||||
await afterPublishedChange(courseId);
|
||||
return json(r);
|
||||
}
|
||||
return json(r, r.reason === "no-remote" ? 400 : r.reason === "network" ? 502 : r.reason === "forbidden" ? 403 : 409);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
if (opts.watch !== false) {
|
||||
watcher.check().catch((e) => console.error("[studio] watcher", e));
|
||||
watcher.start();
|
||||
}
|
||||
|
||||
return { name: "studio", routes, store, watcher, draftWriter, stop: () => watcher.stop() };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { $ } from "bun";
|
||||
|
||||
export interface Identity { name: string; email: string }
|
||||
export interface GitResult { code: number; stdout: string; stderr: string }
|
||||
|
||||
/** Settings that make studio git runs deterministic, whatever the machine's global config says. */
|
||||
const BASE = [
|
||||
"-c", "core.hooksPath=/dev/null",
|
||||
"-c", "commit.gpgsign=false",
|
||||
"-c", "core.quotepath=false",
|
||||
"-c", "merge.conflictstyle=merge",
|
||||
"-c", "pull.rebase=false",
|
||||
"-c", "init.defaultBranch=main",
|
||||
"-c", "protocol.ext.allow=never", // author-supplied remote URLs must never run commands
|
||||
];
|
||||
|
||||
function env(who?: Identity): Record<string, string> {
|
||||
const e: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(process.env)) {
|
||||
if (v !== undefined && !k.startsWith("GIT_")) e[k] = v; // never inherit GIT_DIR, GIT_INDEX_FILE, ...
|
||||
}
|
||||
e.GIT_TERMINAL_PROMPT = "0";
|
||||
// Ignore the machine's global/system git config (url.*.insteadOf, credential helpers, ...).
|
||||
e.GIT_CONFIG_GLOBAL = "/dev/null";
|
||||
e.GIT_CONFIG_NOSYSTEM = "1";
|
||||
e.GIT_SSH_COMMAND = "ssh -o BatchMode=yes";
|
||||
// A fallback identity so git never refuses a merge/commit; real saves always pass `who`.
|
||||
const id = who ?? { name: "STCourses", email: "studio@stcourses.invalid" };
|
||||
e.GIT_AUTHOR_NAME = id.name;
|
||||
e.GIT_AUTHOR_EMAIL = id.email;
|
||||
e.GIT_COMMITTER_NAME = id.name;
|
||||
e.GIT_COMMITTER_EMAIL = id.email;
|
||||
return e;
|
||||
}
|
||||
|
||||
/** Runs `git <args>` in `cwd`. Never throws on a non-zero exit; use `gitOk` for that. */
|
||||
export async function git(cwd: string, args: string[], who?: Identity): Promise<GitResult> {
|
||||
const r = await $`git ${BASE} ${args}`.cwd(cwd).env(env(who)).quiet().nothrow();
|
||||
return { code: r.exitCode, stdout: r.stdout.toString(), stderr: r.stderr.toString() };
|
||||
}
|
||||
|
||||
export class GitError extends Error {
|
||||
constructor(public args: string[], public result: GitResult) {
|
||||
super(`git ${args.join(" ")} failed (${result.code}): ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function gitOk(cwd: string, args: string[], who?: Identity): Promise<string> {
|
||||
const r = await git(cwd, args, who);
|
||||
if (r.code !== 0) throw new GitError(args, r);
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
/** NUL-separated output (`-z`) to a list, so paths with spaces or newlines survive. */
|
||||
export function splitZ(out: string): string[] {
|
||||
return out.split("\0").filter(Boolean);
|
||||
}
|
||||
|
||||
const locks = new Map<string, Promise<unknown>>();
|
||||
|
||||
/** Serialises git work per key (the course's bare repo path) across every store instance in the process. */
|
||||
export function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = locks.get(key) ?? Promise.resolve();
|
||||
const next = prev.then(fn, fn);
|
||||
const tail = next.catch(() => {});
|
||||
locks.set(key, tail);
|
||||
tail.then(() => { if (locks.get(key) === tail) locks.delete(key); });
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { join, resolve } from "node:path";
|
||||
import type { AppConfig } from "@stc/contracts";
|
||||
|
||||
/** Where learners read studio courses from: `<dataDir>/worktrees/<id>/published` (depth 2; drafts sit at depth 3 and are never discovered). */
|
||||
export function studioWorktreesDir(dataDir: string): string {
|
||||
return join(dataDir, "worktrees");
|
||||
}
|
||||
|
||||
/** Studio bare repos: `<dataDir>/studio/repos/<id>.git` (no manifest, never discovered). */
|
||||
export function studioReposDir(dataDir: string): string {
|
||||
return join(dataDir, "studio", "repos");
|
||||
}
|
||||
|
||||
/**
|
||||
* The course dirs the CourseRepository must scan: the configured ones, the importer's plain folders in
|
||||
* `<dataDir>/courses`, and studio courses through their published worktree in `<dataDir>/worktrees`.
|
||||
* Phase 0's server builds the repository from `config.courseDirs` only; see docs/contract-requests/studio.md.
|
||||
*/
|
||||
export function studioCourseDirs(config: Pick<AppConfig, "dataDir" | "courseDirs">): string[] {
|
||||
const dirs = config.courseDirs.map((d) => resolve(d));
|
||||
for (const d of [resolve(config.dataDir, "courses"), resolve(studioWorktreesDir(config.dataDir))]) if (!dirs.includes(d)) dirs.push(d);
|
||||
return dirs;
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
import { constants, existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
|
||||
import { mkdir, open, rm, unlink } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, join, normalize, sep } from "node:path";
|
||||
import { loadCourse, type Diagnostic } from "@stc/course-format";
|
||||
import { git, gitOk, splitZ, withLock, type Identity } from "./git";
|
||||
|
||||
export type { Identity } from "./git";
|
||||
|
||||
export const MANIFEST = "stcourse.yaml";
|
||||
const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
export function contentHash(content: string | Uint8Array): string {
|
||||
return new Bun.CryptoHasher("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
/** Branch- and path-safe form of a user id. Distinct ids may collide only if they differ in unsafe characters. */
|
||||
export function safeAuthor(authorId: string): string {
|
||||
const s = authorId.replace(/[^A-Za-z0-9_-]/g, "_");
|
||||
return s === "" ? "_" : s;
|
||||
}
|
||||
|
||||
/** Normalised relative path, or throws. Rejects absolute paths, `..`, `.git` and NUL. */
|
||||
export function checkRelPath(p: string): string {
|
||||
if (typeof p !== "string" || p === "" || p.includes("\0") || isAbsolute(p) || p.includes("\\")) throw new Error(`invalid path: ${JSON.stringify(p)}`);
|
||||
const n = normalize(p);
|
||||
const parts = n.split(sep);
|
||||
if (n === "." || parts[0] === ".." || parts.some((x) => x === ".." || x.toLowerCase() === ".git") || n.endsWith(sep)) {
|
||||
throw new Error(`invalid path: ${JSON.stringify(p)}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export interface FileWrite {
|
||||
path: string;
|
||||
content?: string | Uint8Array;
|
||||
/** Hash of the content the editor started from: `null` = the file must not exist; omitted = no check. */
|
||||
baseHash?: string | null;
|
||||
delete?: boolean;
|
||||
}
|
||||
|
||||
export interface FileConflict { path: string; current: string | null; currentHash: string | null }
|
||||
export type SaveResult = { ok: true; commit: string | null } | { ok: false; conflicts: FileConflict[] };
|
||||
|
||||
export interface DraftStatus {
|
||||
courseId: string;
|
||||
branch: string;
|
||||
publishedBranch: string;
|
||||
draftHead: string;
|
||||
publishedHead: string;
|
||||
/** Draft commits not yet published. */
|
||||
ahead: number;
|
||||
/** Published commits (external edits, pulls, other authors' publishes) not yet in the draft. */
|
||||
behind: number;
|
||||
merging: boolean;
|
||||
conflicts: string[];
|
||||
remote: string | null;
|
||||
/** Uncommitted changes in the published worktree (someone editing it outside the studio). */
|
||||
publishedDirty: string[];
|
||||
}
|
||||
|
||||
export type PublishResult =
|
||||
| { ok: true; commit: string; published: boolean }
|
||||
| { ok: false; reason: "conflict"; conflicts: string[] }
|
||||
| { ok: false; reason: "dirty"; files: string[] }
|
||||
| { ok: false; reason: "invalid"; diagnostics: Diagnostic[] }
|
||||
| { ok: false; reason: "forbidden"; message: string }
|
||||
| { ok: false; reason: "merging" };
|
||||
|
||||
export type MergeResult = { ok: true; commit: string } | { ok: false; reason: "conflict"; conflicts: string[] } | { ok: false; reason: "merging" | "no-remote" | "dirty"; message: string };
|
||||
|
||||
export interface ConflictFile { path: string; base: string | null; ours: string | null; theirs: string | null; working: string | null }
|
||||
|
||||
export type SyncResult =
|
||||
| { ok: true; pulled: boolean; pushed: boolean; head: string }
|
||||
| { ok: false; reason: "no-remote" | "dirty" | "network"; message: string }
|
||||
| { ok: false; reason: "conflict"; conflicts: string[] }
|
||||
| { ok: false; reason: "forbidden"; message: string };
|
||||
|
||||
export class StudioError extends Error {
|
||||
constructor(message: string, public status = 400) { super(message); }
|
||||
}
|
||||
|
||||
const HOST = String.raw`[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?`;
|
||||
const USERINFO = String.raw`(?:[^@/\s:]+(?::[^@/\s]*)?@)?`;
|
||||
const REMOTE_RES = [
|
||||
new RegExp(String.raw`^https://${USERINFO}${HOST}(?::\d+)?/[^\s]*$`),
|
||||
new RegExp(String.raw`^ssh://${USERINFO}${HOST}(?::\d+)?/[^\s]*$`),
|
||||
new RegExp(String.raw`^git@${HOST}:[A-Za-z0-9_~.][^\s]*$`),
|
||||
];
|
||||
|
||||
/** Only https://, ssh:// and git@host: remotes. Local paths, file://, ext::, fd:: and the rest are refused. */
|
||||
export function isAllowedRemote(url: string): boolean {
|
||||
return REMOTE_RES.some((re) => re.test(url));
|
||||
}
|
||||
|
||||
/** Transport hardening passed to every fetch/push, on top of the allowlist (defence in depth). */
|
||||
const REMOTE_GUARD = ["-c", "protocol.file.allow=never", "-c", "protocol.ext.allow=never", "-c", "protocol.fd.allow=never"];
|
||||
|
||||
type ManifestLike = { visibility?: string; source?: { kind?: string } } | null;
|
||||
const kindOf = (m: ManifestLike) => m?.source?.kind ?? "authored";
|
||||
function parseLoose(text: string | null): ManifestLike {
|
||||
if (text === null) return null;
|
||||
try { return (Bun.YAML.parse(text) as ManifestLike) ?? null; } catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Spec 12: only the owner may make an imported course public or signed-in, and (to close the obvious bypass)
|
||||
* only the owner may change `source.kind`. Returns a reason when `resultText` needs the owner, else null.
|
||||
*/
|
||||
export function ownerRequiredFor(baseText: string | null, resultText: string | null): string | null {
|
||||
const base = parseLoose(baseText);
|
||||
const result = parseLoose(resultText);
|
||||
if (!result) return null;
|
||||
if (base && kindOf(base) !== kindOf(result)) return "only the owner can change source.kind";
|
||||
const vis = result.visibility ?? "restricted";
|
||||
if ((vis === "public" || vis === "signed-in") && (kindOf(result) !== "authored" || (base && kindOf(base) !== "authored"))) {
|
||||
return "only the owner can make an imported course public or signed-in visible";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type Actor = Identity & { id: string; owner?: boolean };
|
||||
|
||||
export interface StudioStoreOptions {
|
||||
dataDir: string;
|
||||
publishedBranch?: string;
|
||||
/** Tests only: allow local-path remotes (the HTTP feature never sets this). */
|
||||
allowLocalRemotes?: boolean;
|
||||
}
|
||||
|
||||
export function createStudioStore(opts: StudioStoreOptions) {
|
||||
const dataDir = opts.dataDir;
|
||||
const main = opts.publishedBranch ?? "main";
|
||||
// Studio repos live apart from the importer's plain folders in <dataDir>/courses (controller ruling, review I4).
|
||||
const reposDir = join(dataDir, "studio", "repos");
|
||||
const importedDir = join(dataDir, "courses");
|
||||
const worktreesDir = join(dataDir, "worktrees");
|
||||
const remoteGuard = opts.allowLocalRemotes ? ["-c", "protocol.ext.allow=never", "-c", "protocol.fd.allow=never"] : REMOTE_GUARD;
|
||||
|
||||
const paths = (courseId: string) => {
|
||||
if (!ID_RE.test(courseId)) throw new StudioError(`invalid course id: ${courseId}`, 404);
|
||||
const root = join(worktreesDir, courseId);
|
||||
return {
|
||||
bare: join(reposDir, `${courseId}.git`),
|
||||
root,
|
||||
published: join(root, "published"),
|
||||
draftsDir: join(root, "drafts"),
|
||||
draft: (authorId: string) => join(root, "drafts", safeAuthor(authorId)),
|
||||
};
|
||||
};
|
||||
const branchOf = (authorId: string) => `draft/${safeAuthor(authorId)}`;
|
||||
|
||||
const isManaged = (courseId: string): boolean => {
|
||||
if (!ID_RE.test(courseId)) return false;
|
||||
const p = paths(courseId);
|
||||
return existsSync(join(p.bare, "HEAD")) && existsSync(join(p.published, ".git"));
|
||||
};
|
||||
const requireManaged = (courseId: string) => {
|
||||
if (!isManaged(courseId)) throw new StudioError(`course ${courseId} is not managed by the studio`, 404);
|
||||
return paths(courseId);
|
||||
};
|
||||
const lock = <T>(courseId: string, fn: () => Promise<T>) => withLock(paths(courseId).bare, fn);
|
||||
|
||||
const rev = async (cwd: string, ref: string) => (await gitOk(cwd, ["rev-parse", ref])).trim();
|
||||
const count = async (cwd: string, range: string) => Number((await gitOk(cwd, ["rev-list", "--count", range])).trim());
|
||||
const unmerged = async (cwd: string) => [...new Set(splitZ(await gitOk(cwd, ["diff", "--name-only", "-z", "--diff-filter=U"])))];
|
||||
const isMerging = async (cwd: string) => (await git(cwd, ["rev-parse", "-q", "--verify", "MERGE_HEAD"])).code === 0;
|
||||
const dirtyFiles = async (cwd: string) =>
|
||||
splitZ(await gitOk(cwd, ["status", "--porcelain", "-z", "--untracked-files=all"])).map((l) => l.slice(3)).filter(Boolean);
|
||||
const remoteUrl = async (bare: string) => {
|
||||
const r = await git(bare, ["remote", "get-url", "origin"]);
|
||||
return r.code === 0 ? r.stdout.trim() : null;
|
||||
};
|
||||
const remoteOk = (url: string) => opts.allowLocalRemotes ? !/^\s*-/.test(url) && !/^(ext|fd)::/.test(url) : isAllowedRemote(url);
|
||||
/** The configured remote, re-checked on every use (it could have been set outside the studio). */
|
||||
const usableRemote = async (bare: string): Promise<{ ok: true } | { ok: false; message: string }> => {
|
||||
const url = await remoteUrl(bare);
|
||||
if (!url) return { ok: false, message: "no remote is configured for this course" };
|
||||
if (!remoteOk(url)) return { ok: false, message: "the configured remote is not an https://, ssh:// or git@host: URL" };
|
||||
return { ok: true };
|
||||
};
|
||||
const showText = async (cwd: string, spec: string): Promise<string | null> => {
|
||||
const r = await git(cwd, ["show", spec]);
|
||||
return r.code === 0 ? r.stdout : null;
|
||||
};
|
||||
|
||||
const lexists = (p: string) => { try { lstatSync(p); return true; } catch { return false; } };
|
||||
|
||||
/**
|
||||
* For writes (review C1): refuses when any existing component of `rel`, the final one included, is a symlink
|
||||
* (lstat, so dangling links count), creates missing parents, then re-checks the parent's real path is inside.
|
||||
*/
|
||||
const safeWritePath = async (root: string, rel: string, opts: { allowFinalSymlink?: boolean } = {}): Promise<string> => {
|
||||
const n = checkRelPath(rel);
|
||||
const realRoot = realpathSync(root);
|
||||
const parts = n.split(sep);
|
||||
let cur = realRoot;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
cur = join(cur, parts[i]!);
|
||||
if (!lexists(cur)) break;
|
||||
const st = lstatSync(cur);
|
||||
const last = i === parts.length - 1;
|
||||
if (st.isSymbolicLink() && !(last && opts.allowFinalSymlink)) throw new Error(`invalid path (symlink): ${rel}`);
|
||||
if (!last && !st.isDirectory()) throw new Error(`invalid path (not a directory): ${rel}`);
|
||||
}
|
||||
const abs = join(realRoot, n);
|
||||
await mkdir(dirname(abs), { recursive: true });
|
||||
const realParent = realpathSync(dirname(abs));
|
||||
if (realParent !== realRoot && !realParent.startsWith(realRoot + sep)) throw new Error(`invalid path (escapes the course): ${rel}`);
|
||||
return abs;
|
||||
};
|
||||
|
||||
/** For reads: absolute path for `rel` whose real path (symlinks followed) stays inside `root`. */
|
||||
const inside = (root: string, rel: string): string => {
|
||||
const n = checkRelPath(rel);
|
||||
const abs = join(root, n);
|
||||
const realRoot = realpathSync(root);
|
||||
let probe = dirname(abs);
|
||||
while (!existsSync(probe)) probe = dirname(probe);
|
||||
const realProbe = realpathSync(probe);
|
||||
if (realProbe !== realRoot && !realProbe.startsWith(realRoot + sep)) throw new Error(`invalid path (escapes the course): ${rel}`);
|
||||
if (existsSync(abs)) {
|
||||
const real = realpathSync(abs);
|
||||
if (real !== realRoot && !real.startsWith(realRoot + sep)) throw new Error(`invalid path (escapes the course): ${rel}`);
|
||||
}
|
||||
return abs;
|
||||
};
|
||||
|
||||
async function ensureDraftUnlocked(courseId: string, authorId: string): Promise<string> {
|
||||
const p = requireManaged(courseId);
|
||||
const dir = p.draft(authorId);
|
||||
if (existsSync(join(dir, ".git"))) return dir;
|
||||
mkdirSync(p.draftsDir, { recursive: true });
|
||||
await git(p.bare, ["worktree", "prune"]);
|
||||
const branch = branchOf(authorId);
|
||||
const exists = (await git(p.bare, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`])).code === 0;
|
||||
await gitOk(p.bare, exists ? ["worktree", "add", dir, branch] : ["worktree", "add", "-b", branch, dir, main]);
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function readFileIn(dir: string, rel: string): Promise<{ content: string; hash: string } | null> {
|
||||
let abs: string;
|
||||
try { abs = inside(dir, rel); } catch { return null; }
|
||||
if (!existsSync(abs) || !statSync(abs).isFile()) return null;
|
||||
const bytes = new Uint8Array(await Bun.file(abs).arrayBuffer());
|
||||
return { content: new TextDecoder().decode(bytes), hash: contentHash(bytes) };
|
||||
}
|
||||
|
||||
async function commitAll(dir: string, who: Identity, message: string, pathspecs?: string[]): Promise<string | null> {
|
||||
await gitOk(dir, ["add", "-A", "--", ...(pathspecs ?? ["."])]);
|
||||
if ((await git(dir, ["diff", "--cached", "--quiet"])).code === 0) return null;
|
||||
await gitOk(dir, ["commit", "-q", "-m", message], who);
|
||||
return rev(dir, "HEAD");
|
||||
}
|
||||
|
||||
async function writeFiles(dir: string, files: FileWrite[]): Promise<string[]> {
|
||||
const touched: string[] = [];
|
||||
for (const f of files) {
|
||||
if (f.delete) {
|
||||
// unlink removes a symlink itself, never its target, so deleting a planted link is allowed
|
||||
const abs = await safeWritePath(dir, f.path, { allowFinalSymlink: true });
|
||||
if (lexists(abs)) await unlink(abs);
|
||||
} else {
|
||||
const abs = await safeWritePath(dir, f.path);
|
||||
// O_NOFOLLOW: a symlink swapped in after the check makes the open fail instead of writing through it
|
||||
const fh = await open(abs, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o644);
|
||||
try { await fh.writeFile(f.content ?? ""); } finally { await fh.close(); }
|
||||
}
|
||||
touched.push(checkRelPath(f.path));
|
||||
}
|
||||
return touched;
|
||||
}
|
||||
|
||||
const api = {
|
||||
dataDir,
|
||||
publishedBranch: main,
|
||||
paths,
|
||||
branchOf,
|
||||
isManaged,
|
||||
|
||||
async listManaged(): Promise<string[]> {
|
||||
if (!existsSync(reposDir)) return [];
|
||||
return readdirSync(reposDir).filter((n) => n.endsWith(".git")).map((n) => n.slice(0, -4)).filter((id) => ID_RE.test(id) && isManaged(id)).sort();
|
||||
},
|
||||
|
||||
async createCourse(input: { id: string; title: string; pitch?: string }, who: Identity): Promise<void> {
|
||||
const { id, title } = input;
|
||||
if (!ID_RE.test(id)) throw new StudioError(`invalid course id "${id}": use lowercase letters, digits and dashes`);
|
||||
if (!title.trim()) throw new StudioError("title is required");
|
||||
const p = paths(id);
|
||||
await lock(id, async () => {
|
||||
if (existsSync(p.bare) || existsSync(p.root) || existsSync(join(importedDir, id))) throw new StudioError(`course "${id}" already exists`, 409);
|
||||
mkdirSync(reposDir, { recursive: true });
|
||||
const tmp = join(dataDir, "tmp", `new-${id}-${Date.now()}`);
|
||||
mkdirSync(join(tmp, "lessons"), { recursive: true });
|
||||
try {
|
||||
await gitOk(dataDir, ["init", "-q", "--bare", "-b", main, p.bare]);
|
||||
const manifest = {
|
||||
schema: 1, id, title: title.trim(), ...(input.pitch ? { pitch: input.pitch } : {}),
|
||||
authors: [who.name], visibility: "restricted", source: { kind: "authored" },
|
||||
modules: [{ id: "m1", title: "Getting started", lessons: [{ id: "welcome", title: "Welcome", type: "text", file: "lessons/welcome.md" }] }],
|
||||
};
|
||||
await Bun.write(join(tmp, MANIFEST), Bun.YAML.stringify(manifest, null, 2) + "\n");
|
||||
await Bun.write(join(tmp, "lessons/welcome.md"), `# Welcome\n\nThis is the first lesson of ${title.trim()}.\n`);
|
||||
const gd = ["--git-dir", p.bare, "--work-tree", tmp];
|
||||
await gitOk(tmp, [...gd, "add", "-A"]);
|
||||
await gitOk(tmp, [...gd, "commit", "-q", "-m", `Create course ${id}`], who);
|
||||
mkdirSync(p.root, { recursive: true });
|
||||
await gitOk(p.bare, ["worktree", "add", p.published, main]);
|
||||
} catch (e) {
|
||||
rmSync(p.bare, { recursive: true, force: true });
|
||||
rmSync(p.root, { recursive: true, force: true });
|
||||
throw e;
|
||||
} finally {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
ensureDraft: (courseId: string, authorId: string) => lock(courseId, () => ensureDraftUnlocked(courseId, authorId)),
|
||||
|
||||
async listFiles(courseId: string, authorId: string): Promise<string[]> {
|
||||
const dir = await api.ensureDraft(courseId, authorId);
|
||||
const tracked = splitZ(await gitOk(dir, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]));
|
||||
return [...new Set(tracked)].filter((f) => existsSync(join(dir, f))).sort();
|
||||
},
|
||||
|
||||
async readFile(courseId: string, authorId: string, rel: string) {
|
||||
const dir = await api.ensureDraft(courseId, authorId);
|
||||
return readFileIn(dir, rel);
|
||||
},
|
||||
|
||||
async readPublished(courseId: string, rel: string) {
|
||||
return readFileIn(requireManaged(courseId).published, rel);
|
||||
},
|
||||
|
||||
draftDir: (courseId: string, authorId: string) => api.ensureDraft(courseId, authorId),
|
||||
|
||||
async saveFiles(courseId: string, who: Actor, files: FileWrite[], message: string): Promise<SaveResult> {
|
||||
for (const f of files) checkRelPath(f.path);
|
||||
if (files.length === 0) throw new StudioError("nothing to save");
|
||||
return lock(courseId, async () => {
|
||||
const dir = await ensureDraftUnlocked(courseId, who.id);
|
||||
if (await isMerging(dir)) throw new StudioError("the draft has a merge in progress: resolve or abort it first", 409);
|
||||
const conflicts: FileConflict[] = [];
|
||||
for (const f of files) {
|
||||
if (f.baseHash === undefined) continue;
|
||||
const cur = await readFileIn(dir, f.path);
|
||||
if ((cur?.hash ?? null) !== f.baseHash) conflicts.push({ path: checkRelPath(f.path), current: cur?.content ?? null, currentHash: cur?.hash ?? null });
|
||||
}
|
||||
if (conflicts.length) return { ok: false, conflicts };
|
||||
const touched = await writeFiles(dir, files);
|
||||
const commit = await commitAll(dir, who, message.trim() || "Edit in studio", touched);
|
||||
return { ok: true, commit };
|
||||
});
|
||||
},
|
||||
|
||||
async status(courseId: string, authorId: string): Promise<DraftStatus> {
|
||||
const p = requireManaged(courseId);
|
||||
const dir = await api.ensureDraft(courseId, authorId);
|
||||
const branch = branchOf(authorId);
|
||||
const merging = await isMerging(dir);
|
||||
return {
|
||||
courseId, branch, publishedBranch: main,
|
||||
draftHead: await rev(dir, "HEAD"),
|
||||
publishedHead: await rev(p.bare, main),
|
||||
ahead: await count(p.bare, `${main}..${branch}`),
|
||||
behind: await count(p.bare, `${branch}..${main}`),
|
||||
merging,
|
||||
conflicts: merging ? await unmerged(dir) : [],
|
||||
remote: await remoteUrl(p.bare),
|
||||
publishedDirty: await dirtyFiles(p.published),
|
||||
};
|
||||
},
|
||||
|
||||
/** List view status, read from the bare repo only: never creates a worktree or branch (review I5). */
|
||||
async summary(courseId: string, authorId: string): Promise<{ hasDraft: boolean; ahead: number; behind: number; merging: boolean; remote: string | null }> {
|
||||
const p = requireManaged(courseId);
|
||||
const branch = branchOf(authorId);
|
||||
const hasDraft = (await git(p.bare, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`])).code === 0;
|
||||
const dir = p.draft(authorId);
|
||||
return {
|
||||
hasDraft,
|
||||
ahead: hasDraft ? await count(p.bare, `${main}..${branch}`) : 0,
|
||||
behind: hasDraft ? await count(p.bare, `${branch}..${main}`) : 0,
|
||||
merging: existsSync(join(dir, ".git")) ? await isMerging(dir) : false,
|
||||
remote: await remoteUrl(p.bare),
|
||||
};
|
||||
},
|
||||
|
||||
/** The published manifest text (base for the owner-only rule). */
|
||||
async publishedManifest(courseId: string): Promise<string | null> {
|
||||
return showText(requireManaged(courseId).bare, `${main}:${MANIFEST}`);
|
||||
},
|
||||
|
||||
async publishedHead(courseId: string): Promise<string> {
|
||||
return rev(requireManaged(courseId).published, "HEAD");
|
||||
},
|
||||
|
||||
/** Validates the draft as the learner loader would; error diagnostics block publishing. */
|
||||
async validateDraft(courseId: string, authorId: string): Promise<Diagnostic[]> {
|
||||
return api.validateDraftUnlocked(courseId, await api.ensureDraft(courseId, authorId));
|
||||
},
|
||||
|
||||
publish(courseId: string, who: Actor): Promise<PublishResult> {
|
||||
return lock(courseId, async () => {
|
||||
const p = requireManaged(courseId);
|
||||
const dir = await ensureDraftUnlocked(courseId, who.id);
|
||||
const branch = branchOf(who.id);
|
||||
if (await isMerging(dir)) return { ok: false, reason: "merging" };
|
||||
const dirty = await dirtyFiles(p.published);
|
||||
if (dirty.length) return { ok: false, reason: "dirty", files: dirty };
|
||||
const diags = (await api.validateDraftUnlocked(courseId, dir)).filter((d) => d.level === "error");
|
||||
if (diags.length) return { ok: false, reason: "invalid", diagnostics: diags };
|
||||
if ((await count(p.bare, `${main}..${branch}`)) === 0) return { ok: true, commit: await rev(p.published, "HEAD"), published: false };
|
||||
const baseManifest = await showText(p.published, `HEAD:${MANIFEST}`);
|
||||
// Merge without committing, so the merged result is checked before it becomes the published version.
|
||||
const r = await git(p.published, ["merge", "--no-ff", "--no-commit", branch], who);
|
||||
if (r.code !== 0) {
|
||||
const conflicts = await unmerged(p.published);
|
||||
await git(p.published, ["merge", "--abort"]);
|
||||
if (!conflicts.length) throw new StudioError(`publish failed: ${r.stderr.trim() || r.stdout.trim()}`, 500);
|
||||
return { ok: false, reason: "conflict", conflicts };
|
||||
}
|
||||
const merged = (await readFileIn(p.published, MANIFEST))?.content ?? null;
|
||||
const needOwner = ownerRequiredFor(baseManifest, merged);
|
||||
if (needOwner && !who.owner) {
|
||||
await git(p.published, ["merge", "--abort"]);
|
||||
return { ok: false, reason: "forbidden", message: needOwner };
|
||||
}
|
||||
const mergedErrors = (await api.validateDraftUnlocked(courseId, p.published)).filter((d) => d.level === "error");
|
||||
if (mergedErrors.length) {
|
||||
await git(p.published, ["merge", "--abort"]);
|
||||
return { ok: false, reason: "invalid", diagnostics: mergedErrors };
|
||||
}
|
||||
await gitOk(p.published, ["commit", "-q", "-m", `Publish ${branch}`], who);
|
||||
// Bring the draft level with what was just published, so "behind" stays meaningful.
|
||||
await git(dir, ["merge", "-q", "--ff-only", main]);
|
||||
return { ok: true, commit: await rev(p.published, "HEAD"), published: true };
|
||||
});
|
||||
},
|
||||
|
||||
async validateDraftUnlocked(courseId: string, dir: string): Promise<Diagnostic[]> {
|
||||
const c = await loadCourse(join(dir, MANIFEST));
|
||||
const diags = [...c.diagnostics];
|
||||
if (c.manifest && c.manifest.id !== courseId) diags.push({ level: "error", courseId, path: MANIFEST, message: `manifest id "${c.manifest.id}" must stay "${courseId}"` });
|
||||
if (c.manifest?.root) diags.push({ level: "error", courseId, path: MANIFEST, message: "studio courses cannot set root" });
|
||||
return diags;
|
||||
},
|
||||
|
||||
/** Merges the published branch (or the remote's) into the author's draft. Conflicts stay in the draft for the author to resolve. */
|
||||
updateDraft(courseId: string, who: Actor, from: "published" | "remote" = "published"): Promise<MergeResult> {
|
||||
return lock(courseId, async () => {
|
||||
const p = requireManaged(courseId);
|
||||
const dir = await ensureDraftUnlocked(courseId, who.id);
|
||||
if (await isMerging(dir)) return { ok: false, reason: "merging", message: "a merge is already in progress" };
|
||||
let target = main;
|
||||
if (from === "remote") {
|
||||
const ru = await usableRemote(p.bare);
|
||||
if (!ru.ok) return { ok: false, reason: "no-remote", message: ru.message };
|
||||
const f = await git(p.bare, [...remoteGuard, "fetch", "-q", "origin"]);
|
||||
if (f.code !== 0) return { ok: false, reason: "no-remote", message: f.stderr.trim() };
|
||||
target = `origin/${main}`;
|
||||
if ((await git(p.bare, ["rev-parse", "-q", "--verify", `refs/remotes/${target}`])).code !== 0) return { ok: true, commit: await rev(dir, "HEAD") };
|
||||
}
|
||||
const r = await git(dir, ["merge", "--no-ff", "-m", `Update draft from ${target}`, target], who);
|
||||
if (r.code !== 0) {
|
||||
const conflicts = await unmerged(dir);
|
||||
if (!conflicts.length) {
|
||||
await git(dir, ["merge", "--abort"]);
|
||||
throw new StudioError(`update failed: ${r.stderr.trim() || r.stdout.trim()}`, 500);
|
||||
}
|
||||
return { ok: false, reason: "conflict", conflicts };
|
||||
}
|
||||
return { ok: true, commit: await rev(dir, "HEAD") };
|
||||
});
|
||||
},
|
||||
|
||||
async conflicts(courseId: string, authorId: string): Promise<ConflictFile[]> {
|
||||
const dir = await api.ensureDraft(courseId, authorId);
|
||||
if (!(await isMerging(dir))) return [];
|
||||
const stage = async (n: number, path: string) => {
|
||||
const r = await git(dir, ["show", `:${n}:${path}`]);
|
||||
return r.code === 0 ? r.stdout : null;
|
||||
};
|
||||
const out: ConflictFile[] = [];
|
||||
for (const path of await unmerged(dir)) {
|
||||
out.push({ path, base: await stage(1, path), ours: await stage(2, path), theirs: await stage(3, path), working: (await readFileIn(dir, path))?.content ?? null });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
/** Writes the author's resolution (`content: null` deletes) and commits the merge once nothing is unmerged. */
|
||||
resolve(courseId: string, who: Actor, files: { path: string; content: string | null }[]): Promise<{ ok: true; remaining: string[]; commit: string | null }> {
|
||||
return lock(courseId, async () => {
|
||||
const dir = await ensureDraftUnlocked(courseId, who.id);
|
||||
if (!(await isMerging(dir))) throw new StudioError("no merge in progress", 409);
|
||||
const m = files.find((f) => checkRelPath(f.path) === MANIFEST);
|
||||
if (m && !who.owner) {
|
||||
const needOwner = ownerRequiredFor(await showText(requireManaged(courseId).bare, `${main}:${MANIFEST}`), m.content);
|
||||
if (needOwner) throw new StudioError(needOwner, 403);
|
||||
}
|
||||
const touched = await writeFiles(dir, files.map((f) => (f.content === null ? { path: f.path, delete: true } : { path: f.path, content: f.content })));
|
||||
await gitOk(dir, ["add", "-A", "--", ...touched]);
|
||||
const remaining = await unmerged(dir);
|
||||
if (remaining.length) return { ok: true, remaining, commit: null };
|
||||
await gitOk(dir, ["commit", "-q", "--no-edit"], who);
|
||||
return { ok: true, remaining: [], commit: await rev(dir, "HEAD") };
|
||||
});
|
||||
},
|
||||
|
||||
abortMerge(courseId: string, authorId: string): Promise<void> {
|
||||
return lock(courseId, async () => {
|
||||
const dir = await ensureDraftUnlocked(courseId, authorId);
|
||||
if (await isMerging(dir)) await gitOk(dir, ["merge", "--abort"]);
|
||||
});
|
||||
},
|
||||
|
||||
async remote(courseId: string): Promise<string | null> {
|
||||
return remoteUrl(requireManaged(courseId).bare);
|
||||
},
|
||||
|
||||
setRemote(courseId: string, url: string | null): Promise<void> {
|
||||
return lock(courseId, async () => {
|
||||
const p = requireManaged(courseId);
|
||||
const cur = await remoteUrl(p.bare);
|
||||
if (!url) {
|
||||
if (cur) await gitOk(p.bare, ["remote", "remove", "origin"]);
|
||||
return;
|
||||
}
|
||||
if (!remoteOk(url.trim())) throw new StudioError("remote must be an https://, ssh:// or git@host: URL");
|
||||
await gitOk(p.bare, cur ? ["remote", "set-url", "origin", url.trim()] : ["remote", "add", "origin", url.trim()]);
|
||||
// Bare repos created with `init --bare` have no fetch refspec for remote-tracking branches by default.
|
||||
await gitOk(p.bare, ["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"]);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Explicit Sync with the remote: pull (fast-forward, or a merge without conflicts) and push the published branch.
|
||||
* A conflicting divergence is aborted and reported; the author merges the remote into the draft to resolve it.
|
||||
*/
|
||||
sync(courseId: string, who: Actor): Promise<SyncResult> {
|
||||
return lock(courseId, async () => {
|
||||
const p = requireManaged(courseId);
|
||||
const ru = await usableRemote(p.bare);
|
||||
if (!ru.ok) return { ok: false, reason: "no-remote", message: ru.message };
|
||||
const dirty = await dirtyFiles(p.published);
|
||||
if (dirty.length) return { ok: false, reason: "dirty", message: `uncommitted changes in the published worktree: ${dirty.join(", ")}` };
|
||||
const f = await git(p.bare, [...remoteGuard, "fetch", "-q", "origin"]);
|
||||
if (f.code !== 0) return { ok: false, reason: "network", message: f.stderr.trim() || "fetch failed" };
|
||||
const remoteRef = `refs/remotes/origin/${main}`;
|
||||
const hasRemote = (await git(p.bare, ["rev-parse", "-q", "--verify", remoteRef])).code === 0;
|
||||
let pulled = false;
|
||||
let pushed = false;
|
||||
if (hasRemote) {
|
||||
const local = await rev(p.bare, main);
|
||||
const remote = await rev(p.bare, remoteRef);
|
||||
if (local !== remote) {
|
||||
const localBehind = (await git(p.bare, ["merge-base", "--is-ancestor", local, remote])).code === 0;
|
||||
const remoteBehind = (await git(p.bare, ["merge-base", "--is-ancestor", remote, local])).code === 0;
|
||||
const baseManifest = await showText(p.bare, `${main}:${MANIFEST}`);
|
||||
if (localBehind) {
|
||||
const needOwner = ownerRequiredFor(baseManifest, await showText(p.bare, `${remoteRef}:${MANIFEST}`));
|
||||
if (needOwner && !who.owner) return { ok: false, reason: "forbidden", message: needOwner };
|
||||
await gitOk(p.published, ["merge", "-q", "--ff-only", remoteRef]);
|
||||
pulled = true;
|
||||
} else if (!remoteBehind) {
|
||||
const r = await git(p.published, ["merge", "--no-ff", "--no-commit", remoteRef], who);
|
||||
if (r.code !== 0) {
|
||||
const conflicts = await unmerged(p.published);
|
||||
await git(p.published, ["merge", "--abort"]);
|
||||
return conflicts.length ? { ok: false, reason: "conflict", conflicts } : { ok: false, reason: "network", message: r.stderr.trim() };
|
||||
}
|
||||
const needOwner = ownerRequiredFor(baseManifest, (await readFileIn(p.published, MANIFEST))?.content ?? null);
|
||||
if (needOwner && !who.owner) {
|
||||
await git(p.published, ["merge", "--abort"]);
|
||||
return { ok: false, reason: "forbidden", message: needOwner };
|
||||
}
|
||||
await gitOk(p.published, ["commit", "-q", "-m", `Sync with origin/${main}`], who);
|
||||
pulled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const ahead = hasRemote ? await count(p.bare, `${remoteRef}..${main}`) : 1;
|
||||
if (ahead > 0) {
|
||||
const r = await git(p.bare, [...remoteGuard, "push", "-q", "origin", `${main}:${main}`]);
|
||||
if (r.code !== 0) return { ok: false, reason: "network", message: r.stderr.trim() || "push failed" };
|
||||
await git(p.bare, [...remoteGuard, "fetch", "-q", "origin"]);
|
||||
pushed = true;
|
||||
}
|
||||
return { ok: true, pulled, pushed, head: await rev(p.published, "HEAD") };
|
||||
});
|
||||
},
|
||||
};
|
||||
return api;
|
||||
}
|
||||
|
||||
export type StudioStore = ReturnType<typeof createStudioStore>;
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { CourseRepository } from "@stc/course-format";
|
||||
import type { StudioStore } from "./store";
|
||||
|
||||
export interface PublishedWatcher {
|
||||
/** Compares every managed course's published HEAD with the last one seen; reloads the repository for changed ones. */
|
||||
check(): Promise<string[]>;
|
||||
/** Records a head the studio itself just produced (publish, sync) after reloading. */
|
||||
note(courseId: string, head: string): void;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spec 12: "The platform watches the published worktree and reloads when it changes, after an external commit or a pull."
|
||||
* Polling `git rev-parse HEAD` is portable (fs.watch on .git internals is not) and costs one git spawn per course per tick.
|
||||
*/
|
||||
export function createPublishedWatcher(store: StudioStore, courses: CourseRepository, opts: { intervalMs?: number } = {}): PublishedWatcher {
|
||||
const seen = new Map<string, string>();
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let running: Promise<string[]> | null = null;
|
||||
|
||||
const run = async (): Promise<string[]> => {
|
||||
const changed: string[] = [];
|
||||
for (const id of await store.listManaged()) {
|
||||
let head: string;
|
||||
try { head = await store.publishedHead(id); } catch { continue; }
|
||||
const prev = seen.get(id);
|
||||
seen.set(id, head);
|
||||
if (prev === undefined ? courses.get(id) === null : prev !== head) changed.push(id);
|
||||
}
|
||||
for (const id of changed) await courses.reload(id);
|
||||
return changed;
|
||||
};
|
||||
|
||||
const w: PublishedWatcher = {
|
||||
check() {
|
||||
running ??= run().finally(() => { running = null; });
|
||||
return running;
|
||||
},
|
||||
note(courseId, head) { seen.set(courseId, head); },
|
||||
start() {
|
||||
if (timer) return;
|
||||
timer = setInterval(() => { w.check().catch((e) => console.error("[studio] watcher", e)); }, opts.intervalMs ?? 3000);
|
||||
(timer as { unref?: () => void }).unref?.();
|
||||
},
|
||||
stop() {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
},
|
||||
};
|
||||
return w;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
import { CourseCover, Pill } from "@stc/ui";
|
||||
import { api, coursePath, uniqueId, type Diagnostic, type ManifestLesson } from "./api";
|
||||
import { AccessMessage } from "./StudioHome";
|
||||
import { clone, useWorkspace, type Workspace } from "./workspace";
|
||||
import { CourseSettings } from "./editors/CourseSettings";
|
||||
import { LessonEditor } from "./editors/LessonEditor";
|
||||
import { AttachmentsEditor, DecksEditor } from "./editors/Media";
|
||||
import { ConflictView, RawManifest, SyncPanel } from "./editors/GitPanels";
|
||||
import { Field } from "./editors/common";
|
||||
import "./studio.css";
|
||||
|
||||
type Notice = { kind: "ok" | "warn" | "bad"; text: string; files?: string[]; diagnostics?: Diagnostic[]; offerUpdate?: boolean };
|
||||
|
||||
export default function CourseStudio() {
|
||||
const { courseId = "" } = useParams();
|
||||
const { ws, readOnly, error } = useWorkspace(courseId);
|
||||
|
||||
if (error) return <div className="studio studio-page"><p className="chap"><Link to="/studio">Studio</Link></p>{error.status === 404 ? <div className="studio-note studio-note--bad"><strong>No such course.</strong></div> : <AccessMessage {...error} />}</div>;
|
||||
if (readOnly) {
|
||||
return (
|
||||
<div className="studio studio-page">
|
||||
<p className="chap"><Link to="/studio">Studio</Link></p>
|
||||
<h1>{readOnly.title}</h1>
|
||||
<div className="studio-note studio-note--warn" role="note"><strong>{readOnly.message}.</strong> It is read-only here; edit it in its own repository at <code>{readOnly.root}</code>.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!ws) return <div className="studio studio-page"><p className="faint">Loading…</p></div>;
|
||||
return <Workbench ws={ws} />;
|
||||
}
|
||||
|
||||
function Workbench({ ws }: { ws: Workspace }) {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const view = ws.state.status.merging ? "conflicts" : params.get("view") ?? "settings";
|
||||
const lessonId = params.get("lesson");
|
||||
const go = (v: string, lesson?: string) => {
|
||||
if (ws.docDirty && !window.confirm("You have unsaved changes to the course structure. Leave them unsaved for now?")) return;
|
||||
setParams(lesson ? { view: v, lesson } : { view: v });
|
||||
};
|
||||
const [notice, setNotice] = useState<Notice | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const st = ws.state.status;
|
||||
const hasErrors = ws.state.manifestErrors.length > 0 || ws.state.diagnostics.some((d) => d.level === "error");
|
||||
|
||||
const publish = async () => {
|
||||
setBusy(true);
|
||||
const { status, data } = await api("POST", coursePath(ws.courseId, "/publish"));
|
||||
setBusy(false);
|
||||
if (status === 200) setNotice(data.published ? { kind: "ok", text: "Published. Learners now see this version." } : { kind: "ok", text: "Nothing new to publish." });
|
||||
else if (data?.reason === "conflict") setNotice({ kind: "warn", text: "Not published: the published version was changed outside your draft, in the same places you edited. Nothing was overwritten. Update your draft, resolve the conflicts, then publish.", files: data.conflicts, offerUpdate: true });
|
||||
else if (data?.reason === "dirty") setNotice({ kind: "warn", text: "Not published: someone has unsaved edits in the published folder on the server. Ask them to commit or discard them first.", files: data.files });
|
||||
else if (data?.reason === "invalid") setNotice({ kind: "bad", text: "Not published: the draft has errors.", diagnostics: data.diagnostics });
|
||||
else setNotice({ kind: "bad", text: data?.error ?? `Publish failed (${status})` });
|
||||
await ws.reload();
|
||||
};
|
||||
const updateDraft = async () => {
|
||||
setBusy(true);
|
||||
const { status, data } = await api("POST", coursePath(ws.courseId, "/update-draft"), { from: "published" });
|
||||
setBusy(false);
|
||||
setNotice(status === 200 ? { kind: "ok", text: "Your draft now includes the latest published changes." } : status === 409 && data?.reason === "conflict" ? null : { kind: "bad", text: data?.message ?? data?.error });
|
||||
await ws.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="studio studio-ws">
|
||||
<aside className="studio-aside" aria-label="Course outline">
|
||||
<div className="studio-side-head" style={{ display: "flex", gap: 12 }}>
|
||||
<CourseCover title={ws.doc.title} accent={ws.doc.accent} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h2>{ws.doc.title}</h2>
|
||||
<div className="src"><Link to="/studio">Studio</Link> · <code>{ws.courseId}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="studio-nav">
|
||||
{[["settings", "Course settings"], ["decks", "Deck links"], ["attachments", "Attachments"], ["manifest", "stcourse.yaml"], ["sync", "Sync"]].map(([v, label]) => (
|
||||
<li key={v}><button type="button" aria-current={view === v ? "page" : undefined} onClick={() => go(v!)}>{label}{v === "sync" && st.remote ? <span className="k">remote</span> : null}</button></li>
|
||||
))}
|
||||
{st.merging ? <li><button type="button" aria-current="page">Conflicts <span className="k">{st.conflicts.length}</span></button></li> : null}
|
||||
</ul>
|
||||
{ws.doc.modules.map((m) => (
|
||||
<ul className="studio-nav" key={m.id} aria-label={m.title}>
|
||||
<li className="mod">{m.title}</li>
|
||||
{m.lessons.map((l) => (
|
||||
<li key={l.id}>
|
||||
<button type="button" aria-current={view === "lesson" && lessonId === l.id ? "page" : undefined} onClick={() => go("lesson", l.id)}>
|
||||
<span>{l.title}</span><span className="k">{l.type === "text" ? "" : l.type}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li><AddLesson ws={ws} moduleId={m.id} onAdded={(id) => setParams({ view: "lesson", lesson: id })} /></li>
|
||||
</ul>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<div className="studio-content">
|
||||
<div className="studio-bar" role="region" aria-label="Draft status">
|
||||
<div className="where">
|
||||
Editing your draft <b><code>{st.branch}</code></b>
|
||||
{" · "}{st.ahead ? <b>{st.ahead} unpublished {st.ahead === 1 ? "change" : "changes"}</b> : "nothing unpublished"}
|
||||
{st.behind ? <> · <b>{st.behind} new on published</b> since your draft</> : null}
|
||||
{ws.docDirty ? <> · <b>unsaved structure changes</b></> : null}
|
||||
</div>
|
||||
{st.behind && !st.merging ? <Pill disabled={busy} onClick={updateDraft}>Update draft</Pill> : null}
|
||||
<Pill variant="primary" disabled={busy || st.merging || !st.ahead || hasErrors} title={hasErrors ? "Fix the errors in the draft first" : undefined} onClick={publish}>Publish</Pill>
|
||||
</div>
|
||||
|
||||
{notice ? (
|
||||
<div className={`studio-note studio-note--${notice.kind}`} role="status">
|
||||
{notice.text}
|
||||
{notice.files?.length ? <ul className="studio-errors" style={{ color: "var(--text)" }}>{notice.files.map((f) => <li key={f}>{f}</li>)}</ul> : null}
|
||||
{notice.diagnostics?.length ? <ul className="studio-errors">{notice.diagnostics.map((d, i) => <li key={i}>{d.lessonId ? `${d.lessonId}: ` : ""}{d.message}</li>)}</ul> : null}
|
||||
{notice.offerUpdate ? <div className="studio-actions" style={{ marginTop: 8 }}><Pill onClick={updateDraft}>Update draft and resolve</Pill></div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{view === "conflicts" ? <ConflictView ws={ws} />
|
||||
: view === "lesson" && lessonId ? <LessonEditor key={lessonId} ws={ws} lessonId={lessonId} onGone={() => setParams({ view: "settings" })} />
|
||||
: view === "decks" ? <DecksEditor ws={ws} />
|
||||
: view === "attachments" ? <AttachmentsEditor ws={ws} />
|
||||
: view === "manifest" ? <RawManifest ws={ws} />
|
||||
: view === "sync" ? <SyncPanel ws={ws} />
|
||||
: <CourseSettings ws={ws} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Adds a lesson: manifest entry plus its Markdown file, saved in one commit. */
|
||||
function AddLesson({ ws, moduleId, onAdded }: { ws: Workspace; moduleId: string; onAdded: (id: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [type, setType] = useState<ManifestLesson["type"]>("text");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
if (!open) return <button type="button" className="add" onClick={() => setOpen(true)}>+ Add lesson</button>;
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const taken = new Set(ws.doc.modules.flatMap((m) => m.lessons.map((l) => l.id)));
|
||||
const id = uniqueId(title, taken);
|
||||
const doc = clone(ws.doc);
|
||||
const lesson: ManifestLesson = type === "video"
|
||||
? { id, title: title.trim(), type, file: `media/${id}.mp4`, transcript: `lessons/${id}.md` }
|
||||
: { id, title: title.trim(), type, file: `lessons/${id}.md` };
|
||||
doc.modules.find((m) => m.id === moduleId)!.lessons.push(lesson);
|
||||
const r = await ws.save({ manifest: doc, files: [{ path: `lessons/${id}.md`, content: `# ${title.trim()}\n\n`, baseHash: null }], message: `Add lesson ${id}` });
|
||||
if (!r.ok) return setErr(r.error ?? "A file with that name already exists");
|
||||
setOpen(false); setTitle("");
|
||||
onAdded(id);
|
||||
};
|
||||
return (
|
||||
<form onSubmit={submit} style={{ display: "grid", gap: 8, padding: "6px 16px 10px" }}>
|
||||
<Field label="Lesson title"><input type="text" autoFocus required value={title} onChange={(e) => setTitle(e.target.value)} /></Field>
|
||||
<Field label="Type">
|
||||
<select value={type} onChange={(e) => setType(e.target.value as ManifestLesson["type"])}>
|
||||
<option value="text">Text</option><option value="video">Video</option><option value="exercise">Exercise</option>
|
||||
</select>
|
||||
</Field>
|
||||
{err ? <span className="studio-errors" role="alert">{err}</span> : null}
|
||||
<div className="studio-actions"><Pill type="submit" variant="primary" disabled={!title.trim()}>Add</Pill><Pill variant="ghost" onClick={() => setOpen(false)}>Cancel</Pill></div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { CourseCover, Pill } from "@stc/ui";
|
||||
import { api, slug } from "./api";
|
||||
import "./studio.css";
|
||||
|
||||
interface CourseRow {
|
||||
id: string; title: string; pitch: string | null; accent: string | null; managed: boolean; publishedStatus: "ok" | "error";
|
||||
ahead?: number; behind?: number; merging?: boolean; remote?: string | null; message?: string;
|
||||
}
|
||||
|
||||
export function AccessMessage({ status, error }: { status: number; error?: string }) {
|
||||
if (status === 401) return <div className="studio-note studio-note--warn"><strong>Sign in to use the studio.</strong> The studio is for course authors and the owner.</div>;
|
||||
if (status === 403) return <div className="studio-note studio-note--warn"><strong>The studio is for authors.</strong> Ask the owner for author access.</div>;
|
||||
return <div className="studio-note studio-note--bad"><strong>Something went wrong.</strong> {error}</div>;
|
||||
}
|
||||
|
||||
export default function StudioHome() {
|
||||
const [rows, setRows] = useState<CourseRow[] | null>(null);
|
||||
const [canCreate, setCanCreate] = useState(false);
|
||||
const [err, setErr] = useState<{ status: number; error?: string } | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [id, setId] = useState("");
|
||||
const [idTouched, setIdTouched] = useState(false);
|
||||
const [createErr, setCreateErr] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const nav = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
api<{ courses: CourseRow[]; canCreate: boolean; error?: string }>("GET", "/api/studio/courses").then(({ status, data }) => {
|
||||
if (status !== 200) return setErr({ status, error: data?.error });
|
||||
setRows(data.courses);
|
||||
setCanCreate(data.canCreate);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const create = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setCreateErr(null);
|
||||
const { status, data } = await api("POST", "/api/studio/courses", { id, title });
|
||||
setBusy(false);
|
||||
if (status === 201) nav(`/studio/${encodeURIComponent(data.id)}`);
|
||||
else setCreateErr(data?.error ?? `Could not create the course (${status})`);
|
||||
};
|
||||
|
||||
const managed = rows?.filter((r) => r.managed) ?? [];
|
||||
const outside = rows?.filter((r) => !r.managed) ?? [];
|
||||
|
||||
return (
|
||||
<div className="studio studio-page">
|
||||
<p className="chap">Studio</p>
|
||||
<h1>Your courses</h1>
|
||||
<p className="studio-lede">Edit on your own draft, save as you go, and publish when it is ready. Learners only see what you publish.</p>
|
||||
{err ? <AccessMessage {...err} /> : null}
|
||||
|
||||
{canCreate ? (
|
||||
<section className="studio-section" style={{ marginTop: 26 }} aria-labelledby="new-course">
|
||||
<h2 id="new-course">New course</h2>
|
||||
<form className="studio-form studio-form--inline" onSubmit={create}>
|
||||
<label className="studio-field" style={{ flex: "2 1 260px" }}>
|
||||
<span>Title</span>
|
||||
<input type="text" required value={title} onChange={(e) => { setTitle(e.target.value); if (!idTouched) setId(slug(e.target.value)); }} />
|
||||
</label>
|
||||
<label className="studio-field" style={{ flex: "1 1 180px" }}>
|
||||
<span>Course id</span>
|
||||
<input type="text" className="code" required pattern="[a-z0-9][a-z0-9-]*" value={id} onChange={(e) => { setId(e.target.value); setIdTouched(true); }} />
|
||||
</label>
|
||||
<Pill type="submit" variant="primary" disabled={busy || !title.trim() || !id}>Create course</Pill>
|
||||
</form>
|
||||
{createErr ? <p className="studio-errors" role="alert">{createErr}</p> : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{rows ? (
|
||||
<section className="studio-section" aria-labelledby="studio-courses">
|
||||
<h2 id="studio-courses">Studio courses</h2>
|
||||
{managed.length === 0 ? <p className="faint">No studio courses yet.</p> : (
|
||||
<ul className="studio-rows">
|
||||
{managed.map((c) => (
|
||||
<li className="studio-row" key={c.id}>
|
||||
<CourseCover title={c.title} accent={c.accent ?? undefined} />
|
||||
<div className="t">
|
||||
<Link to={`/studio/${encodeURIComponent(c.id)}`}>{c.title}</Link>
|
||||
<span className="s"><code>{c.id}</code>{c.pitch ? ` · ${c.pitch}` : ""}</span>
|
||||
</div>
|
||||
{c.merging ? <span className="studio-tag studio-tag--accent">Resolving conflicts</span> : null}
|
||||
{c.ahead ? <span className="studio-tag studio-tag--accent">{c.ahead} unpublished</span> : null}
|
||||
{c.behind ? <span className="studio-tag">{c.behind} new on published</span> : null}
|
||||
{c.remote ? <span className="studio-tag">Remote</span> : null}
|
||||
{c.publishedStatus === "error" ? <span className="studio-tag">Published version has errors</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{outside.length ? (
|
||||
<section className="studio-section" aria-labelledby="outside-courses">
|
||||
<h2 id="outside-courses">Managed outside STCourses</h2>
|
||||
<p className="faint" style={{ margin: 0 }}>These courses come from repositories the platform does not own. Edit them in their own repository.</p>
|
||||
<ul className="studio-rows">
|
||||
{outside.map((c) => (
|
||||
<li className="studio-row" key={c.id}>
|
||||
<CourseCover title={c.title} accent={c.accent ?? undefined} />
|
||||
<div className="t">
|
||||
<Link to={`/studio/${encodeURIComponent(c.id)}`}>{c.title}</Link>
|
||||
<span className="s"><code>{c.id}</code> · {c.message}</span>
|
||||
</div>
|
||||
<span className="studio-tag">Read-only</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Browser client for /api/studio. Types mirror the server responses (kept here so no server module enters the bundle).
|
||||
|
||||
export interface ApiError { status: number; body: any }
|
||||
|
||||
export async function api<T = any>(method: string, path: string, body?: unknown): Promise<{ status: number; data: T }> {
|
||||
const init: RequestInit = { method, headers: {} };
|
||||
if (body instanceof FormData) init.body = body;
|
||||
else if (body !== undefined) {
|
||||
(init.headers as Record<string, string>)["content-type"] = "application/json";
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const r = await fetch(path, init);
|
||||
const text = await r.text();
|
||||
let data: any = null;
|
||||
try { data = text ? JSON.parse(text) : null; } catch { data = { error: text }; }
|
||||
return { status: r.status, data };
|
||||
}
|
||||
|
||||
export const coursePath = (id: string, rest = "") => `/api/studio/courses/${encodeURIComponent(id)}${rest}`;
|
||||
export const rawUrl = (id: string, rel: string) => coursePath(id, `/raw/${rel.split("/").map(encodeURIComponent).join("/")}`);
|
||||
|
||||
export interface ManifestLesson {
|
||||
id: string; title: string; type: "text" | "video" | "exercise"; file?: string; anchor?: string; duration?: number; transcript?: string;
|
||||
hints?: { file: string; match: string };
|
||||
browser?: { language: "js" | "ts"; starter: string; tests: string; solution?: string };
|
||||
localCommand?: string | Record<string, string>;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
export interface ManifestModule { id: string; title: string; lessons: ManifestLesson[]; [k: string]: unknown }
|
||||
export interface ManifestDeck { id: string; file: string; title?: string; attachTo: string }
|
||||
export interface ManifestAttachment { file: string; title?: string; module?: string }
|
||||
export interface ManifestDoc {
|
||||
schema: 1; id: string; title: string; pitch?: string; authors?: string[]; cover?: string; accent?: string; collections?: string[];
|
||||
visibility?: "public" | "signed-in" | "restricted"; gating?: "none" | "sequential"; mentor?: string;
|
||||
decks?: ManifestDeck[]; attachments?: ManifestAttachment[]; modules: ManifestModule[]; [k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DraftStatus {
|
||||
branch: string; publishedBranch: string; draftHead: string; publishedHead: string; ahead: number; behind: number;
|
||||
merging: boolean; conflicts: string[]; remote: string | null; publishedDirty: string[];
|
||||
}
|
||||
export interface Diagnostic { level: "error" | "warning"; lessonId?: string; path?: string; message: string }
|
||||
|
||||
export interface CourseState {
|
||||
readOnly: false; course: { id: string; title: string }; status: DraftStatus; manifestText: string; manifestHash: string | null;
|
||||
manifestRaw: ManifestDoc | null; manifestErrors: string[]; diagnostics: Diagnostic[]; files: string[];
|
||||
}
|
||||
export interface ReadOnlyState { readOnly: true; message: string; course: { id: string; title: string; root: string }; diagnostics: Diagnostic[] }
|
||||
|
||||
export interface FileWrite { path: string; content?: string; baseHash?: string | null; delete?: boolean }
|
||||
export interface FileConflict { path: string; current: string | null; currentHash: string | null }
|
||||
|
||||
export const slug = (s: string) => s.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item";
|
||||
export const uniqueId = (base: string, taken: Set<string>) => {
|
||||
let id = slug(base);
|
||||
for (let n = 2; taken.has(id); n++) id = `${slug(base)}-${n}`;
|
||||
return id;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from "react";
|
||||
import { Pill } from "@stc/ui";
|
||||
import { uniqueId } from "../api";
|
||||
import type { Workspace } from "../workspace";
|
||||
import { DiagnosticsList, Errors, Field, FilePicker, SaveBar, isImage, isMarkdown, useManifestValidation } from "./common";
|
||||
|
||||
const list = (s: string) => s.split(",").map((x) => x.trim()).filter(Boolean);
|
||||
|
||||
/** Manifest fields editor (structured), with live validation from @stc/course-format. */
|
||||
export function CourseSettings({ ws }: { ws: Workspace }) {
|
||||
const { doc, setDoc, state } = ws;
|
||||
const errors = useManifestValidation(ws, { manifest: doc });
|
||||
const [newModule, setNewModule] = useState("");
|
||||
const set = <K extends keyof typeof doc>(k: K, v: (typeof doc)[K] | undefined) => setDoc((d) => {
|
||||
if (v === undefined || v === "") delete d[k]; else d[k] = v;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="studio-page" style={{ maxWidth: 760, margin: 0 }}>
|
||||
<p className="chap">Course settings</p>
|
||||
<h1>{doc.title || "Untitled course"}</h1>
|
||||
<p className="studio-lede">These fields live in <code>stcourse.yaml</code>. Changes go to your draft when you save.</p>
|
||||
|
||||
<div className="studio-form" style={{ marginTop: 22 }}>
|
||||
<Field label="Title"><input type="text" value={doc.title} onChange={(e) => set("title", e.target.value)} /></Field>
|
||||
<Field label="Pitch" hint="One or two sentences shown in the library.">
|
||||
<textarea rows={2} value={doc.pitch ?? ""} onChange={(e) => set("pitch", e.target.value)} />
|
||||
</Field>
|
||||
<div className="grid2">
|
||||
<Field label="Authors" hint="Comma-separated."><input type="text" value={(doc.authors ?? []).join(", ")} onChange={(e) => set("authors", list(e.target.value))} /></Field>
|
||||
<Field label="Collections" hint="Comma-separated."><input type="text" value={(doc.collections ?? []).join(", ")} onChange={(e) => set("collections", list(e.target.value))} /></Field>
|
||||
<Field label="Visibility">
|
||||
<select value={doc.visibility ?? "restricted"} onChange={(e) => set("visibility", e.target.value as never)}>
|
||||
<option value="restricted">Restricted: named users or groups</option>
|
||||
<option value="signed-in">Signed-in: any user</option>
|
||||
<option value="public">Public: anyone</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Module gating">
|
||||
<select value={doc.gating ?? "none"} onChange={(e) => set("gating", e.target.value as never)}>
|
||||
<option value="none">Off</option>
|
||||
<option value="sequential">Sequential: a module unlocks when the previous one is done</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Accent colour" hint="Optional, as #rrggbb."><input type="text" className="code" value={doc.accent ?? ""} onChange={(e) => set("accent", e.target.value)} /></Field>
|
||||
<Field label="Cover image"><FilePicker files={state.files} filter={isImage} allowEmpty value={doc.cover} onChange={(v) => set("cover", v)} /></Field>
|
||||
<Field label="Mentor rules" hint="Instructions for the AI tutor."><FilePicker files={state.files} filter={isMarkdown} allowEmpty value={doc.mentor} onChange={(v) => set("mentor", v)} /></Field>
|
||||
</div>
|
||||
|
||||
<div className="studio-section">
|
||||
<h2>Modules</h2>
|
||||
<ul className="studio-rows" style={{ marginTop: 0 }}>
|
||||
{doc.modules.map((m, i) => (
|
||||
<li className="studio-row" key={m.id}>
|
||||
<span className="faint" style={{ width: 22 }}>{i + 1}</span>
|
||||
<div className="t"><input type="text" aria-label={`Title of module ${m.id}`} value={m.title} onChange={(e) => setDoc((d) => { d.modules[i]!.title = e.target.value; })} /></div>
|
||||
<span className="faint">{m.lessons.length} lessons</span>
|
||||
<Pill variant="ghost" disabled={i === 0} onClick={() => setDoc((d) => { const [x] = d.modules.splice(i, 1); d.modules.splice(i - 1, 0, x!); })}>Move up</Pill>
|
||||
<Pill variant="ghost" disabled={m.lessons.length > 0} title={m.lessons.length ? "Remove its lessons first" : undefined} onClick={() => setDoc((d) => { d.modules.splice(i, 1); })}>Remove</Pill>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<form className="studio-form studio-form--inline" style={{ marginTop: 12 }} onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!newModule.trim()) return;
|
||||
setDoc((d) => { d.modules.push({ id: uniqueId(newModule, new Set(d.modules.map((x) => x.id))), title: newModule.trim(), lessons: [] }); });
|
||||
setNewModule("");
|
||||
}}>
|
||||
<Field label="New module"><input type="text" value={newModule} onChange={(e) => setNewModule(e.target.value)} /></Field>
|
||||
<Pill type="submit" disabled={!newModule.trim()}>Add module</Pill>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="studio-section">
|
||||
<h2>Validation</h2>
|
||||
{errors.length === 0 && !state.diagnostics.length ? <p className="faint" style={{ margin: 0 }}>The manifest is valid.</p> : null}
|
||||
<Errors errors={errors} />
|
||||
<DiagnosticsList diagnostics={state.diagnostics} />
|
||||
</div>
|
||||
|
||||
<SaveBar ws={ws} dirty={ws.docDirty} collect={() => []} message={() => "Edit course settings"} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Pill } from "@stc/ui";
|
||||
import { api, coursePath } from "../api";
|
||||
import type { Workspace } from "../workspace";
|
||||
import { Errors, Field, SaveBar, useManifestValidation } from "./common";
|
||||
import { useFile } from "../workspace";
|
||||
|
||||
interface ConflictFile { path: string; base: string | null; ours: string | null; theirs: string | null; working: string | null }
|
||||
|
||||
/** Spec 9.1/12: conflicts are shown, and the author decides every file. Nothing is resolved automatically. */
|
||||
export function ConflictView({ ws }: { ws: Workspace }) {
|
||||
const [files, setFiles] = useState<ConflictFile[] | null>(null);
|
||||
const [text, setText] = useState<Record<string, string>>({});
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
api<{ files: ConflictFile[] }>("GET", coursePath(ws.courseId, "/conflicts")).then(({ data }) => {
|
||||
setFiles(data.files);
|
||||
setText(Object.fromEntries(data.files.map((f) => [f.path, f.working ?? ""])));
|
||||
});
|
||||
}, [ws.courseId, ws.state.status.draftHead, ws.state.status.conflicts.join("\0")]);
|
||||
|
||||
const resolve = async (path: string, content: string | null) => {
|
||||
const { status, data } = await api("POST", coursePath(ws.courseId, "/resolve"), { files: [{ path, content }] });
|
||||
setMsg(status === 200 ? (data.remaining.length ? null : "All conflicts resolved. Your draft now includes the published changes; publish when ready.") : data?.error);
|
||||
await ws.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="studio-page" style={{ margin: 0 }}>
|
||||
<p className="chap">Conflicts</p>
|
||||
<h1>Resolve conflicts</h1>
|
||||
<p className="studio-lede">The published version and your draft changed the same lines. For each file, choose a version or edit the result, then mark it resolved. Your draft is not published until you publish it.</p>
|
||||
{msg ? <div className="studio-note studio-note--ok" role="status">{msg}</div> : null}
|
||||
{files?.map((f) => (
|
||||
<section className="studio-conflict" key={f.path} aria-label={`Conflict in ${f.path}`}>
|
||||
<h2><code>{f.path}</code></h2>
|
||||
<div className="studio-sides">
|
||||
<div><span className="faint">Your draft</span><pre>{f.ours ?? "(deleted in your draft)"}</pre></div>
|
||||
<div><span className="faint">Published (changed outside your draft)</span><pre>{f.theirs ?? "(deleted on published)"}</pre></div>
|
||||
</div>
|
||||
<Field label="Result" hint="Starts with both versions between conflict markers. Remove the markers when you are done.">
|
||||
<textarea className="code" rows={12} value={text[f.path] ?? ""} onChange={(e) => setText((t) => ({ ...t, [f.path]: e.target.value }))} />
|
||||
</Field>
|
||||
<div className="studio-actions" style={{ marginTop: 10 }}>
|
||||
<Pill onClick={() => setText((t) => ({ ...t, [f.path]: f.ours ?? "" }))}>Use my draft</Pill>
|
||||
<Pill onClick={() => setText((t) => ({ ...t, [f.path]: f.theirs ?? "" }))}>Use published</Pill>
|
||||
<Pill variant="primary" disabled={/^(<{7}|>{7})/m.test(text[f.path] ?? "")} onClick={() => resolve(f.path, text[f.path] ?? "")}>Mark resolved</Pill>
|
||||
{f.ours === null || f.theirs === null ? <Pill variant="ghost" onClick={() => resolve(f.path, null)}>Delete the file</Pill> : null}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
<div className="studio-section">
|
||||
<Pill variant="ghost" onClick={async () => { await api("POST", coursePath(ws.courseId, "/abort")); await ws.reload(); }}>Cancel the update</Pill>
|
||||
<span className="faint" style={{ marginLeft: 10 }}>Returns your draft to how it was before the update.</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SyncPanel({ ws }: { ws: Workspace }) {
|
||||
const [url, setUrl] = useState(ws.state.status.remote ?? "");
|
||||
const [msg, setMsg] = useState<{ kind: "ok" | "bad" | "warn"; text: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const remote = ws.state.status.remote;
|
||||
|
||||
const saveRemote = async () => {
|
||||
const { status, data } = await api("PUT", coursePath(ws.courseId, "/remote"), { url: url.trim() || null });
|
||||
setMsg(status === 200 ? { kind: "ok", text: data.remote ? "Remote saved." : "Remote removed." } : { kind: "bad", text: data?.error });
|
||||
await ws.reload();
|
||||
};
|
||||
const sync = async () => {
|
||||
setBusy(true);
|
||||
const { status, data } = await api("POST", coursePath(ws.courseId, "/sync"));
|
||||
setBusy(false);
|
||||
if (status === 200) setMsg({ kind: "ok", text: `Synced. ${data.pulled ? "Pulled changes from the remote. " : ""}${data.pushed ? "Pushed the published version." : ""}${!data.pulled && !data.pushed ? "Already up to date." : ""}` });
|
||||
else if (data?.reason === "conflict") setMsg({ kind: "warn", text: `The remote changed the same lines as the published version (${data.conflicts.join(", ")}). Nothing was changed. Update your draft from the remote, resolve the conflicts, publish, then sync again.` });
|
||||
else setMsg({ kind: "bad", text: data?.message ?? data?.error ?? `Sync failed (${status})` });
|
||||
await ws.reload();
|
||||
};
|
||||
const fromRemote = async () => {
|
||||
const { status, data } = await api("POST", coursePath(ws.courseId, "/update-draft"), { from: "remote" });
|
||||
setMsg(status === 200 ? { kind: "ok", text: "Your draft now includes the remote's changes." } : status === 409 ? { kind: "warn", text: "Conflicts to resolve: see Conflicts." } : { kind: "bad", text: data?.message ?? data?.error });
|
||||
await ws.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="studio-page" style={{ maxWidth: 760, margin: 0 }}>
|
||||
<p className="chap">Sync</p>
|
||||
<h1>Remote repository</h1>
|
||||
<p className="studio-lede">Nothing syncs on its own. Sync pulls the remote's published branch and pushes yours, only when you click it.</p>
|
||||
<div className="studio-form" style={{ marginTop: 22 }}>
|
||||
<form className="studio-form studio-form--inline" onSubmit={(e) => { e.preventDefault(); saveRemote(); }}>
|
||||
<Field label="Remote URL" hint="Credentials come from the server (SSH keys or a token in the URL)."><input type="text" className="code" style={{ minWidth: 380 }} value={url} onChange={(e) => setUrl(e.target.value)} placeholder="git@example.org:me/course.git" /></Field>
|
||||
<Pill type="submit">Save remote</Pill>
|
||||
</form>
|
||||
{msg ? <div className={`studio-note studio-note--${msg.kind}`} role="status">{msg.text}</div> : null}
|
||||
<div className="studio-actions">
|
||||
<Pill variant="primary" disabled={!remote || busy} onClick={sync}>{busy ? "Syncing…" : "Sync now"}</Pill>
|
||||
<Pill disabled={!remote} onClick={fromRemote}>Update my draft from the remote</Pill>
|
||||
{!remote ? <span className="faint">Add a remote to enable Sync.</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The whole manifest as YAML, for anything the structured editors do not cover. */
|
||||
export function RawManifest({ ws }: { ws: Workspace }) {
|
||||
const f = useFile(ws.courseId, "stcourse.yaml", ws.version);
|
||||
const errors = useManifestValidation(ws, { manifestText: f.content });
|
||||
return (
|
||||
<div className="studio-page" style={{ maxWidth: 900, margin: 0 }}>
|
||||
<p className="chap">Manifest</p>
|
||||
<h1><code style={{ fontSize: 24 }}>stcourse.yaml</code></h1>
|
||||
<p className="studio-lede">The source of truth for the course structure. Unsaved changes in the other editors are not shown here.</p>
|
||||
<div className="studio-form" style={{ maxWidth: "none", marginTop: 18 }}>
|
||||
<textarea className="code" rows={28} spellCheck={false} value={f.content} onChange={(e) => f.setContent(e.target.value)} aria-label="stcourse.yaml" />
|
||||
{errors.length ? <Errors errors={errors} /> : <p className="faint" style={{ margin: 0 }}>Valid manifest.</p>}
|
||||
<SaveBar ws={ws} dirty={f.dirty} withManifest={false} collect={() => [f.write()!]} message={() => "Edit stcourse.yaml"} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Pill } from "@stc/ui";
|
||||
import type { FileWrite, ManifestLesson } from "../api";
|
||||
import { LessonPreview } from "../preview";
|
||||
import { useFile, type OpenFile, type Workspace } from "../workspace";
|
||||
import { DiagnosticsList, Field, FilePicker, SaveBar, UploadButton, isMarkdown, isVideo, useManifestValidation } from "./common";
|
||||
|
||||
const STARTER = (lang: string) => `// Starter code for the learner.\nexport function solve(input${lang === "ts" ? ": unknown" : ""}) {\n // TODO\n}\n`;
|
||||
const TESTS = `// export const tests: { name, run(mod, assert) }[]\nexport const tests = [\n {\n name: "solves the example",\n run(mod, assert) {\n assert.equal(mod.solve(1), 1);\n },\n },\n];\n`;
|
||||
const HINT = (match: string, label: string) => `<details>\n<summary>${match} — ${label}</summary>\n\nWrite the hint here.\n\n</details>\n`;
|
||||
|
||||
function CodeFile({ label, file, rows = 10 }: { label: string; file: OpenFile; rows?: number }) {
|
||||
if (!file.path) return null;
|
||||
return (
|
||||
<Field label={`${label} · ${file.path}`} hint={file.loaded && !file.exists ? "New file, created when you save." : undefined}>
|
||||
<textarea className="code" rows={rows} spellCheck={false} value={file.content} onChange={(e) => file.setContent(e.target.value)} />
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function LessonEditor({ ws, lessonId, onGone }: { ws: Workspace; lessonId: string; onGone: () => void }) {
|
||||
const { doc, setDoc, state } = ws;
|
||||
let mi = -1, li = -1;
|
||||
doc.modules.forEach((m, i) => m.lessons.forEach((l, j) => { if (l.id === lessonId) { mi = i; li = j; } }));
|
||||
const lesson: ManifestLesson | undefined = mi >= 0 ? doc.modules[mi]!.lessons[li] : undefined;
|
||||
|
||||
// the Markdown body: the lesson file for text/exercise lessons, the transcript for videos
|
||||
const mdPath = lesson ? (lesson.type === "video" ? lesson.transcript : lesson.file && isMarkdown(lesson.file) ? lesson.file : undefined) : undefined;
|
||||
const lang = lesson?.browser?.language ?? "js";
|
||||
const body = useFile(ws.courseId, mdPath, ws.version, lesson ? `# ${lesson.title}\n\n` : "");
|
||||
const starter = useFile(ws.courseId, lesson?.browser?.starter, ws.version, STARTER(lang));
|
||||
const tests = useFile(ws.courseId, lesson?.browser?.tests, ws.version, TESTS);
|
||||
const solution = useFile(ws.courseId, lesson?.browser?.solution, ws.version, STARTER(lang).replace("// TODO", "return input;"));
|
||||
const hints = useFile(ws.courseId, lesson?.hints?.file !== mdPath ? lesson?.hints?.file : undefined, ws.version);
|
||||
const errors = useManifestValidation(ws, { manifest: doc });
|
||||
|
||||
if (!lesson) return <div className="studio-note">This lesson is no longer in the manifest. <Pill variant="ghost" onClick={onGone}>Back to course settings</Pill></div>;
|
||||
|
||||
const upd = (f: (l: ManifestLesson) => void) => setDoc((d) => f(d.modules[mi]!.lessons[li]!));
|
||||
const setOpt = <K extends keyof ManifestLesson>(k: K, v: ManifestLesson[K] | undefined) => upd((l) => { if (v === undefined || v === "") delete l[k]; else l[k] = v; });
|
||||
const files = [body, starter, tests, solution, hints];
|
||||
const dirty = ws.docDirty || files.some((f) => f.dirty);
|
||||
const collect = (): FileWrite[] => files.filter((f) => f.path && (f.dirty || (!f.exists && f.loaded))).map((f) => f.write()!).filter((w, i, a) => a.findIndex((x) => x.path === w.path) === i);
|
||||
const lessonErrors = errors.filter((e) => e.startsWith(`modules.${mi}.lessons.${li}.`));
|
||||
const exDir = `exercises/${lesson.id}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="chap">{doc.modules[mi]!.title} · {lesson.type === "text" ? "Text lesson" : lesson.type === "video" ? "Video lesson" : "Exercise"}</p>
|
||||
<div className="studio-form" style={{ maxWidth: "none" }}>
|
||||
<div className="grid3">
|
||||
<Field label="Title"><input type="text" value={lesson.title} onChange={(e) => upd((l) => { l.title = e.target.value; })} /></Field>
|
||||
<Field label="Type">
|
||||
<select value={lesson.type} onChange={(e) => upd((l) => { l.type = e.target.value as ManifestLesson["type"]; })}>
|
||||
<option value="text">Text</option><option value="video">Video</option><option value="exercise">Exercise</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Duration (minutes)">
|
||||
<input type="number" min={0} step={1} value={lesson.duration ? Math.round(lesson.duration / 60) : ""} onChange={(e) => setOpt("duration", e.target.value ? Number(e.target.value) * 60 : undefined)} />
|
||||
</Field>
|
||||
{lesson.type === "video" ? (
|
||||
<>
|
||||
<Field label="Video file"><FilePicker files={state.files} filter={isVideo} value={lesson.file} onChange={(v) => setOpt("file", v)} /></Field>
|
||||
<Field label="Transcript (Markdown)"><FilePicker files={state.files} filter={isMarkdown} allowEmpty value={lesson.transcript} onChange={(v) => setOpt("transcript", v)} /></Field>
|
||||
<div className="studio-field"><span>Upload</span><div className="studio-actions"><UploadButton ws={ws} dir="media" accept="video/*" label="Upload video" onUploaded={(p) => { setOpt("file", p); }} /></div></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field label="Lesson file"><FilePicker files={state.files} filter={isMarkdown} allowEmpty={lesson.type === "exercise"} value={lesson.file} onChange={(v) => setOpt("file", v)} /></Field>
|
||||
<Field label="Heading anchor" hint="Optional: show only this section of the file."><input type="text" className="code" value={lesson.anchor ?? ""} onChange={(e) => setOpt("anchor", e.target.value)} /></Field>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{lesson.type === "video" && !lesson.transcript ? (
|
||||
<div className="studio-actions"><Pill onClick={() => setOpt("transcript", `lessons/${lesson.id}.md`)}>Add a transcript file</Pill></div>
|
||||
) : null}
|
||||
<Errorsish errors={lessonErrors} ws={ws} lessonId={lesson.id} />
|
||||
|
||||
{mdPath ? (
|
||||
<div className="studio-split">
|
||||
<Field label={`Markdown · ${mdPath}`} hint={body.loaded && !body.exists ? "New file, created when you save." : undefined}>
|
||||
<textarea className="code" spellCheck value={body.content} onChange={(e) => body.setContent(e.target.value)} aria-describedby="preview-label" />
|
||||
</Field>
|
||||
<div className="studio-preview-wrap" aria-live="off">
|
||||
<p className="faint" id="preview-label" style={{ margin: "0 0 10px" }}>Preview</p>
|
||||
<LessonPreview courseId={ws.courseId} markdown={body.content} lesson={lesson} resolveUrl={(rel) => ws.resolveUrl(new URL(rel, `http://x/${mdPath}`).pathname.slice(1))} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{lesson.type === "exercise" ? (
|
||||
<div className="studio-section">
|
||||
<h2>Exercise</h2>
|
||||
<div className="grid2">
|
||||
<Field label="Local test command" hint="Shown to learners who run the exercise on their machine.">
|
||||
<input type="text" className="code" value={typeof lesson.localCommand === "string" ? lesson.localCommand : ""} onChange={(e) => setOpt("localCommand", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Browser runner">
|
||||
<select value={lesson.browser?.language ?? ""} onChange={(e) => {
|
||||
const lang = e.target.value as "js" | "ts" | "";
|
||||
upd((l) => {
|
||||
if (!lang) { delete l.browser; return; }
|
||||
l.browser = l.browser ? { ...l.browser, language: lang } : { language: lang, starter: `${exDir}/starter.${lang}`, tests: `${exDir}/tests.${lang}`, solution: `${exDir}/solution.${lang}` };
|
||||
});
|
||||
}}>
|
||||
<option value="">None (runs locally)</option><option value="js">JavaScript</option><option value="ts">TypeScript</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
{lesson.browser ? (
|
||||
<>
|
||||
<CodeFile label="Starter" file={starter} />
|
||||
<CodeFile label="Tests" file={tests} />
|
||||
<CodeFile label="Solution (authors only)" file={solution} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<h3>Hints</h3>
|
||||
{lesson.hints ? (
|
||||
<>
|
||||
<div className="grid2">
|
||||
<Field label="Hints file"><FilePicker files={state.files} filter={isMarkdown} value={lesson.hints.file} onChange={(v) => upd((l) => { if (v) l.hints = { ...l.hints!, file: v }; })} /></Field>
|
||||
<Field label="Summary label" hint="Hint blocks whose summary starts with this label belong to this exercise.">
|
||||
<input type="text" value={lesson.hints.match} onChange={(e) => upd((l) => { l.hints = { ...l.hints!, match: e.target.value }; })} />
|
||||
</Field>
|
||||
</div>
|
||||
{hints.path ? <CodeFile label="Hint tiers" file={hints} rows={12} /> : <p className="faint">The hints live in the lesson file above.</p>}
|
||||
<div className="studio-actions">
|
||||
{(["nudge", "stronger hint", "near-solution"] as const).map((tier) => (
|
||||
<Pill key={tier} variant="ghost" onClick={() => {
|
||||
const target = hints.path ? hints : body;
|
||||
target.setContent((target.content.trimEnd() ? target.content.trimEnd() + "\n\n" : "") + HINT(lesson.hints!.match, tier));
|
||||
}}>Add {tier}</Pill>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Pill onClick={() => {
|
||||
const match = lesson.title;
|
||||
upd((l) => { l.hints = { file: `${exDir}/hints.md`, match }; });
|
||||
}}>Add hint tiers</Pill>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SaveBar ws={ws} dirty={dirty} collect={collect} message={() => `Edit lesson ${lesson.id}`} extra={
|
||||
<>
|
||||
<Pill variant="ghost" disabled={li === 0} onClick={() => setDoc((d) => { const ls = d.modules[mi]!.lessons; const [x] = ls.splice(li, 1); ls.splice(li - 1, 0, x!); })}>Move up</Pill>
|
||||
<Pill variant="ghost" onClick={() => {
|
||||
if (!window.confirm(`Remove "${lesson.title}" from the course? Its files stay in the repository.`)) return;
|
||||
setDoc((d) => { d.modules[mi]!.lessons.splice(li, 1); });
|
||||
onGone();
|
||||
}}>Remove lesson</Pill>
|
||||
</>
|
||||
} />
|
||||
<p className="faint" style={{ margin: 0 }}>Saved changes stay on your draft. Learners see them after you publish.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Errorsish({ errors, ws, lessonId }: { errors: string[]; ws: Workspace; lessonId: string }) {
|
||||
return (
|
||||
<>
|
||||
{errors.length ? <ul className="studio-errors">{errors.map((e) => <li key={e}>{e}</li>)}</ul> : null}
|
||||
<DiagnosticsList diagnostics={ws.state.diagnostics} filter={(d) => d.lessonId === lessonId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Pill } from "@stc/ui";
|
||||
import { slug, uniqueId, type ManifestDoc } from "../api";
|
||||
import type { Workspace } from "../workspace";
|
||||
import { DiagnosticsList, Field, FilePicker, SaveBar, UploadButton, isPdf } from "./common";
|
||||
|
||||
const targets = (doc: ManifestDoc) => [
|
||||
...doc.modules.map((m) => ({ id: m.id, label: `Module: ${m.title}` })),
|
||||
...doc.modules.flatMap((m) => m.lessons.map((l) => ({ id: l.id, label: `${m.title} · ${l.title}` }))),
|
||||
];
|
||||
|
||||
/** Deck links: a PDF deck attached to a lesson (for example its video) or a module. */
|
||||
export function DecksEditor({ ws }: { ws: Workspace }) {
|
||||
const { doc, setDoc, state } = ws;
|
||||
const decks = doc.decks ?? [];
|
||||
const opts = targets(doc);
|
||||
const add = (file: string) => setDoc((d) => {
|
||||
const ids = new Set((d.decks ?? []).map((x) => x.id));
|
||||
const name = file.split("/").pop()!.replace(/\.pdf$/i, "");
|
||||
(d.decks ??= []).push({ id: uniqueId(slug(name), ids), file, title: name, attachTo: opts[0]?.id ?? "" });
|
||||
});
|
||||
return (
|
||||
<div className="studio-page" style={{ maxWidth: 900, margin: 0 }}>
|
||||
<p className="chap">Deck links</p>
|
||||
<h1>Slide decks</h1>
|
||||
<p className="studio-lede">A deck is a PDF. Link it to the lesson or module it belongs to, and learners see it next to that content.</p>
|
||||
<ul className="studio-rows">
|
||||
{decks.length === 0 ? <li className="studio-row faint">No decks yet.</li> : null}
|
||||
{decks.map((dk, i) => (
|
||||
<li className="studio-row" key={i} style={{ alignItems: "end" }}>
|
||||
<div className="t grid3" style={{ gridTemplateColumns: "1.2fr 1fr 1.2fr" }}>
|
||||
<Field label="Title"><input type="text" value={dk.title ?? ""} onChange={(e) => setDoc((d) => { d.decks![i]!.title = e.target.value || undefined; })} /></Field>
|
||||
<Field label="PDF"><FilePicker files={state.files} filter={isPdf} value={dk.file} onChange={(v) => v && setDoc((d) => { d.decks![i]!.file = v; })} /></Field>
|
||||
<Field label="Attached to">
|
||||
<select value={dk.attachTo} onChange={(e) => setDoc((d) => { d.decks![i]!.attachTo = e.target.value; })}>
|
||||
{opts.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<Pill variant="ghost" onClick={() => setDoc((d) => { d.decks!.splice(i, 1); if (!d.decks!.length) delete d.decks; })}>Remove</Pill>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="studio-actions" style={{ margin: "14px 0 20px" }}>
|
||||
<UploadButton ws={ws} dir="media" accept="application/pdf,.pdf" label="Upload a PDF deck" onUploaded={add} />
|
||||
{state.files.some(isPdf) ? <Pill variant="ghost" onClick={() => add(state.files.find(isPdf)!)}>Link an existing PDF</Pill> : null}
|
||||
</div>
|
||||
<DiagnosticsList diagnostics={state.diagnostics} filter={(d) => /deck/.test(d.message)} />
|
||||
<SaveBar ws={ws} dirty={ws.docDirty} collect={() => []} message={() => "Edit deck links"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Course attachments (PDFs, images, code archives), optionally scoped to a module. */
|
||||
export function AttachmentsEditor({ ws }: { ws: Workspace }) {
|
||||
const { doc, setDoc, state } = ws;
|
||||
const list = doc.attachments ?? [];
|
||||
return (
|
||||
<div className="studio-page" style={{ maxWidth: 900, margin: 0 }}>
|
||||
<p className="chap">Attachments</p>
|
||||
<h1>Attachments</h1>
|
||||
<p className="studio-lede">Files learners can download. Uploads are committed to your draft at once; the list below is saved with the manifest.</p>
|
||||
<ul className="studio-rows">
|
||||
{list.length === 0 ? <li className="studio-row faint">No attachments yet.</li> : null}
|
||||
{list.map((a, i) => (
|
||||
<li className="studio-row" key={i} style={{ alignItems: "end" }}>
|
||||
<div className="t grid3" style={{ gridTemplateColumns: "1.3fr 1fr 1fr" }}>
|
||||
<Field label="File"><a href={ws.resolveUrl(a.file)} className="code" style={{ fontFamily: "var(--mono)", fontSize: 12.5, lineHeight: "34px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{a.file}</a></Field>
|
||||
<Field label="Title"><input type="text" value={a.title ?? ""} onChange={(e) => setDoc((d) => { d.attachments![i]!.title = e.target.value || undefined; })} /></Field>
|
||||
<Field label="Module">
|
||||
<select value={a.module ?? ""} onChange={(e) => setDoc((d) => { if (e.target.value) d.attachments![i]!.module = e.target.value; else delete d.attachments![i]!.module; })}>
|
||||
<option value="">Whole course</option>
|
||||
{doc.modules.map((m) => <option key={m.id} value={m.id}>{m.title}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<Pill variant="ghost" onClick={() => setDoc((d) => { d.attachments!.splice(i, 1); if (!d.attachments!.length) delete d.attachments; })}>Remove</Pill>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="studio-actions" style={{ margin: "14px 0 20px" }}>
|
||||
<UploadButton ws={ws} dir="attachments" label="Upload a file" onUploaded={(p) => setDoc((d) => { (d.attachments ??= []).push({ file: p, title: p.split("/").pop() }); })} />
|
||||
</div>
|
||||
<DiagnosticsList diagnostics={state.diagnostics} filter={(d) => /attachment/.test(d.message)} />
|
||||
<SaveBar ws={ws} dirty={ws.docDirty} collect={() => []} message={() => "Edit attachments"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Pill } from "@stc/ui";
|
||||
import { api, coursePath, type Diagnostic, type FileConflict, type FileWrite } from "../api";
|
||||
import type { SaveOutcome, Workspace } from "../workspace";
|
||||
|
||||
export function Field({ label, hint, children, wide }: { label: string; hint?: ReactNode; children: ReactNode; wide?: boolean }) {
|
||||
return (
|
||||
<label className="studio-field" style={wide ? { gridColumn: "1 / -1" } : undefined}>
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
{hint ? <small>{hint}</small> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Errors({ errors }: { errors: string[] }) {
|
||||
if (!errors.length) return null;
|
||||
return <ul className="studio-errors" aria-live="polite">{errors.map((e, i) => <li key={i}>{e}</li>)}</ul>;
|
||||
}
|
||||
|
||||
export function DiagnosticsList({ diagnostics, filter }: { diagnostics: Diagnostic[]; filter?: (d: Diagnostic) => boolean }) {
|
||||
const list = filter ? diagnostics.filter(filter) : diagnostics;
|
||||
if (!list.length) return null;
|
||||
return <Errors errors={list.map((d) => `${d.level === "warning" ? "warning: " : ""}${d.lessonId ? `${d.lessonId}: ` : ""}${d.message}`)} />;
|
||||
}
|
||||
|
||||
/** Live manifest validation through @stc/course-format on the server, debounced. */
|
||||
export function useManifestValidation(ws: Workspace, input: { manifest?: unknown; manifestText?: string }) {
|
||||
const [errors, setErrors] = useState<string[]>(ws.state.manifestErrors);
|
||||
const key = JSON.stringify(input);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(async () => {
|
||||
const { status, data } = await api("POST", coursePath(ws.courseId, "/validate"), input);
|
||||
if (status === 200) setErrors(data.errors);
|
||||
}, 350);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, ws.courseId]);
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save button with the "changed outside the studio" flow: on a 409 it lists the files and lets the author
|
||||
* either reload the outside version or explicitly keep theirs. Nothing is overwritten without that choice.
|
||||
*/
|
||||
export function SaveBar({ ws, dirty, collect, message, label = "Save draft", onSaved, extra, withManifest }: {
|
||||
ws: Workspace; dirty: boolean; withManifest?: boolean; collect: () => FileWrite[]; message: () => string; label?: string; onSaved?: () => void; extra?: ReactNode;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [conflicts, setConflicts] = useState<FileConflict[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const pending = useRef<FileWrite[]>([]);
|
||||
|
||||
const run = async (files: FileWrite[]) => {
|
||||
setBusy(true); setError(null); setSaved(false);
|
||||
const r: SaveOutcome = await ws.save({ files, message: message(), withManifest });
|
||||
setBusy(false);
|
||||
if (r.ok) { setConflicts(null); setSaved(true); onSaved?.(); return; }
|
||||
if (r.conflicts) { pending.current = files; setConflicts(r.conflicts); }
|
||||
else setError(r.error ?? "Save failed");
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="studio-actions">
|
||||
<Pill variant="primary" disabled={busy || !dirty} onClick={() => run(collect())}>{busy ? "Saving…" : label}</Pill>
|
||||
{extra}
|
||||
{saved && !dirty ? <span className="faint" role="status">Saved to your draft</span> : null}
|
||||
{dirty ? <span className="faint">Unsaved changes</span> : null}
|
||||
</div>
|
||||
{error ? <div className="studio-note studio-note--bad" role="alert">{error}</div> : null}
|
||||
{conflicts ? (
|
||||
<div className="studio-note studio-note--warn" role="alert">
|
||||
<strong>Changed outside the studio since you opened it:</strong> {conflicts.map((c) => c.path).join(", ")}.
|
||||
<div className="studio-sides">
|
||||
{conflicts.map((c) => (
|
||||
<div key={c.path}><span className="faint">{c.path}, current version on disk</span><pre>{c.current ?? "(deleted)"}</pre></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="studio-actions">
|
||||
<Pill onClick={() => { setConflicts(null); ws.reload().then(() => onSaved?.()); }}>Discard mine, load theirs</Pill>
|
||||
<Pill onClick={() => run(pending.current.map((f) => {
|
||||
const c = conflicts.find((x) => x.path === f.path);
|
||||
return c ? { ...f, baseHash: c.currentHash } : f;
|
||||
}))}>Keep mine and save over theirs</Pill>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilePicker({ files, value, onChange, filter, allowEmpty, id }: {
|
||||
files: string[]; value: string | undefined; onChange: (v: string | undefined) => void; filter?: (f: string) => boolean; allowEmpty?: boolean; id?: string;
|
||||
}) {
|
||||
const list = filter ? files.filter(filter) : files;
|
||||
const opts = value && !list.includes(value) ? [value, ...list] : list;
|
||||
return (
|
||||
<select id={id} value={value ?? ""} onChange={(e) => onChange(e.target.value || undefined)}>
|
||||
{allowEmpty || !value ? <option value="">None</option> : null}
|
||||
{opts.map((f) => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export function UploadButton({ ws, dir, accept, label, onUploaded }: { ws: Workspace; dir: string; accept?: string; label: string; onUploaded: (path: string) => void }) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
return (
|
||||
<>
|
||||
<input ref={ref} type="file" accept={accept} className="sr" aria-label={label} tabIndex={-1} onChange={async (e) => {
|
||||
const f = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!f) return;
|
||||
setBusy(true); setErr(null);
|
||||
const r = await ws.upload(f, dir);
|
||||
setBusy(false);
|
||||
if (r.ok) onUploaded(r.path); else setErr(r.error);
|
||||
}} />
|
||||
<Pill onClick={() => ref.current?.click()} disabled={busy}>{busy ? "Uploading…" : label}</Pill>
|
||||
{err ? <span className="studio-errors" role="alert">{err}</span> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const isMarkdown = (f: string) => /\.(md|markdown)$/i.test(f);
|
||||
export const isPdf = (f: string) => /\.pdf$/i.test(f);
|
||||
export const isVideo = (f: string) => /\.(mp4|webm|mov|m4v)$/i.test(f);
|
||||
export const isImage = (f: string) => /\.(png|jpe?g|svg|webp|gif)$/i.test(f);
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* A small, safe Markdown renderer for the studio's live preview (v1 adapter; Phase 2 swaps in the learner's LessonView).
|
||||
* Everything is HTML-escaped; the only raw HTML let through is the bare `<details>`/`<summary>` used for hint tiers.
|
||||
*/
|
||||
|
||||
export interface RenderOptions {
|
||||
/** Maps a relative image/link URL in the lesson to something the browser can load (the draft raw endpoint). */
|
||||
resolveUrl?: (url: string) => string;
|
||||
}
|
||||
|
||||
const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
const unesc = (s: string) => s.replace(/"/g, '"').replace(/>/g, ">").replace(/</g, "<").replace(/&/g, "&");
|
||||
|
||||
/** Same rule as @stc/course-format's slugify (GitHub style), duplicated to keep node-only modules out of the browser bundle. */
|
||||
export function slugify(heading: string): string {
|
||||
return heading
|
||||
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/[`*~]/g, "")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}\p{M}\s_-]/gu, "")
|
||||
.replace(/\s/g, "-");
|
||||
}
|
||||
|
||||
const SAFE_SCHEME = /^(https?:|mailto:|#|\/|\.{0,2}\/|[^:]*$)/i;
|
||||
const URL_PART = String.raw`((?:[^()]|\([^()]*\))*)`;
|
||||
|
||||
function inline(text: string, o: RenderOptions): string {
|
||||
const codes: string[] = [];
|
||||
let s = esc(text).replace(/`([^`]+)`/g, (_, c: string) => `\u0000${codes.push(`<code>${c}</code>`) - 1}\u0000`);
|
||||
const url = (raw: string) => {
|
||||
const u = unesc(raw.trim());
|
||||
if (!SAFE_SCHEME.test(u)) return null;
|
||||
const isRelative = !/^([a-z]+:|\/|#)/i.test(u);
|
||||
return esc(isRelative && o.resolveUrl ? o.resolveUrl(u) : u);
|
||||
};
|
||||
s = s.replace(new RegExp(String.raw`!\[([^\]]*)\]\(` + URL_PART + String.raw`\)`, "g"), (_, alt: string, u: string) => {
|
||||
const href = url(u);
|
||||
return href ? `<img src="${href}" alt="${alt}">` : alt;
|
||||
});
|
||||
s = s.replace(new RegExp(String.raw`\[([^\]]+)\]\(` + URL_PART + String.raw`\)`, "g"), (_, label: string, u: string) => {
|
||||
const href = url(u);
|
||||
return href ? `<a href="${href}">${label}</a>` : label;
|
||||
});
|
||||
s = s.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>").replace(/__(.+?)__/g, "<strong>$1</strong>");
|
||||
s = s.replace(/(^|[^*\w])\*(?!\s)(.+?)\*(?!\w)/g, "$1<em>$2</em>").replace(/(^|[^_\w])_(?!\s)(.+?)_(?!\w)/g, "$1<em>$2</em>");
|
||||
return s.replace(/\u0000(\d+)\u0000/g, (_, i: string) => codes[Number(i)]!);
|
||||
}
|
||||
|
||||
export function renderMarkdown(md: string, o: RenderOptions = {}): string {
|
||||
let lines = md.replace(/\r\n?/g, "\n").split("\n");
|
||||
if (lines[0] === "---") {
|
||||
const end = lines.indexOf("---", 1);
|
||||
if (end > 0) lines = lines.slice(end + 1);
|
||||
}
|
||||
const out: string[] = [];
|
||||
const slugs = new Map<string, number>();
|
||||
let i = 0;
|
||||
const isBlockStart = (l: string) =>
|
||||
/^ {0,3}(#{1,6}\s|```|~~~|>|[-*+]\s|\d+[.)]\s|(-{3,}|\*{3,}|_{3,})\s*$)/.test(l) || /^\s*<\/?(details|summary)>/.test(l);
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!;
|
||||
if (!line.trim()) { i++; continue; }
|
||||
|
||||
const fence = /^ {0,3}(`{3,}|~{3,})\s*([\w+-]*)/.exec(line);
|
||||
if (fence) {
|
||||
const body: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i]!.trimStart().startsWith(fence[1]!)) body.push(lines[i++]!);
|
||||
i++;
|
||||
const lang = fence[2] ? ` data-lang="${esc(fence[2])}"` : "";
|
||||
out.push(`<pre class="code"><code${lang}>${esc(body.join("\n"))}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const h = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
|
||||
if (h) {
|
||||
const base = slugify(h[2]!);
|
||||
const n = slugs.get(base) ?? 0;
|
||||
slugs.set(base, n + 1);
|
||||
const lvl = h[1]!.length;
|
||||
out.push(`<h${lvl} id="${esc(n ? `${base}-${n}` : base)}">${inline(h[2]!, o)}</h${lvl}>`);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^ {0,3}(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { out.push("<hr>"); i++; continue; }
|
||||
|
||||
// hint blocks: <details>, <summary>label</summary>, </details>
|
||||
const det = /^\s*<(\/?)details>\s*$/.exec(line);
|
||||
if (det) { out.push(det[1] ? "</details>" : "<details>"); i++; continue; }
|
||||
const sum = /^\s*(<details>)?\s*<summary>(.*?)<\/summary>\s*$/.exec(line);
|
||||
if (sum) {
|
||||
out.push(`${sum[1] ? "<details>" : ""}<summary>${inline(sum[2]!, o)}</summary>`);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^ {0,3}>/.test(line)) {
|
||||
const body: string[] = [];
|
||||
while (i < lines.length && /^ {0,3}>/.test(lines[i]!)) body.push(lines[i++]!.replace(/^ {0,3}> ?/, ""));
|
||||
out.push(`<blockquote>${renderMarkdown(body.join("\n"), o)}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const list = /^ {0,3}([-*+]|\d+[.)])\s+/.exec(line);
|
||||
if (list) {
|
||||
const ordered = /\d/.test(list[1]!);
|
||||
const re = ordered ? /^ {0,3}\d+[.)]\s+(.*)$/ : /^ {0,3}[-*+]\s+(.*)$/;
|
||||
const items: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const m = re.exec(lines[i]!);
|
||||
if (m) { items.push(m[1]!); i++; continue; }
|
||||
if (lines[i]!.trim() && /^\s{2,}/.test(lines[i]!) && items.length) { items[items.length - 1] += "\n" + lines[i]!.trim(); i++; continue; }
|
||||
break;
|
||||
}
|
||||
const tag = ordered ? "ol" : "ul";
|
||||
out.push(`<${tag}>${items.map((it) => `<li>${inline(it, o)}</li>`).join("")}</${tag}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const para: string[] = [];
|
||||
while (i < lines.length && lines[i]!.trim() && (para.length === 0 || !isBlockStart(lines[i]!))) para.push(lines[i++]!);
|
||||
out.push(`<p>${inline(para.join("\n"), o)}</p>`);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { renderMarkdown } from "./markdown";
|
||||
|
||||
/**
|
||||
* Live-preview adapter. v1 renders with a small built-in Markdown renderer; Phase 2 swaps in the learner
|
||||
* package's `LessonView` via `setLessonPreview` (docs/contract-requests/studio.md).
|
||||
*/
|
||||
export interface LessonPreviewProps {
|
||||
courseId: string;
|
||||
markdown: string;
|
||||
lesson?: { id: string; title: string; type: string; anchor?: string } | null;
|
||||
/** Maps a course-relative path (images, attachments) to a URL that serves the draft file. */
|
||||
resolveUrl: (rel: string) => string;
|
||||
}
|
||||
export type LessonPreviewComponent = ComponentType<LessonPreviewProps>;
|
||||
|
||||
export function SimpleMarkdownPreview({ markdown, resolveUrl }: LessonPreviewProps) {
|
||||
return <div className="studio-preview prose reading" dangerouslySetInnerHTML={{ __html: renderMarkdown(markdown, { resolveUrl }) }} />;
|
||||
}
|
||||
|
||||
let current: LessonPreviewComponent = SimpleMarkdownPreview;
|
||||
export function setLessonPreview(c: LessonPreviewComponent): void {
|
||||
current = c;
|
||||
}
|
||||
export function LessonPreview(props: LessonPreviewProps) {
|
||||
const C = current;
|
||||
return <C {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/* Studio: the denser "Work" look of the mockup. Tokens only; no new colours. */
|
||||
.studio { font-family: var(--sans); font-size: 14px; line-height: 1.55; color: var(--text); }
|
||||
.studio-page { max-width: 1180px; margin: 0 auto; padding-bottom: 64px; }
|
||||
.studio h1 { font: 600 28px/1.12 var(--sans); color: var(--head); margin: 0; letter-spacing: -.012em; }
|
||||
.studio h2 { font: 600 16px/1.3 var(--sans); color: var(--head); margin: 0 0 10px; }
|
||||
.studio h3 { font: 600 14px/1.3 var(--sans); color: var(--head); margin: 18px 0 8px; }
|
||||
.studio .chap { font-size: 13px; font-weight: 500; color: var(--muted); margin: 0 0 10px; }
|
||||
.studio .muted { color: var(--muted); }
|
||||
.studio .faint { color: var(--faint); font-size: 12.5px; }
|
||||
.studio code { font-size: 12.5px; }
|
||||
.studio-lede { color: var(--muted); margin: 8px 0 0; max-width: 760px; }
|
||||
|
||||
/* rows separated by thin rules */
|
||||
.studio-rows { list-style: none; margin: 18px 0 0; padding: 0; border-top: 1px solid var(--line); }
|
||||
.studio-row { display: flex; align-items: center; gap: 14px; padding: 12px 2px; border-bottom: 1px solid var(--line); }
|
||||
.studio-row .t { flex: 1; min-width: 0; }
|
||||
.studio-row .t a, .studio-row .t strong { color: var(--head); font-weight: 500; text-decoration: none; font-size: 14.5px; }
|
||||
.studio-row .t a:hover { text-decoration: underline; }
|
||||
.studio-row .s { display: block; color: var(--faint); font-size: 12.5px; margin-top: 2px; }
|
||||
.studio-tag { display: inline-flex; align-items: center; height: 22px; padding: 0 9px; border: 1px solid var(--line2); border-radius: 11px; font-size: 11.5px; color: var(--muted); white-space: nowrap; }
|
||||
.studio-tag--accent { border-color: var(--accent-dim); color: var(--accent); }
|
||||
|
||||
/* forms */
|
||||
.studio-form { display: grid; gap: 14px; max-width: 760px; }
|
||||
.studio-form--inline { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; max-width: none; }
|
||||
.studio-field { display: grid; gap: 5px; min-width: 0; align-content: start; }
|
||||
.studio-field > span { font-size: 12px; color: var(--faint); }
|
||||
.studio-field small { font-size: 12px; color: var(--faint); }
|
||||
.studio input[type="text"], .studio input[type="url"], .studio input[type="number"], .studio select, .studio textarea {
|
||||
width: 100%; min-width: 0; height: 34px; padding: 0 12px; border: 1px solid var(--line2); border-radius: 8px; background: var(--side); color: var(--text); font: inherit; font-size: 13.5px;
|
||||
}
|
||||
.studio select { padding-right: 8px; }
|
||||
.studio textarea { height: auto; padding: 10px 12px; line-height: 1.55; resize: vertical; }
|
||||
.studio textarea.code, .studio input.code { font-family: var(--mono); font-size: 13px; tab-size: 2; }
|
||||
.studio input:focus, .studio select:focus, .studio textarea:focus { outline: none; border-color: var(--faint); }
|
||||
.studio .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.studio .grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
|
||||
.studio-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
|
||||
/* messages */
|
||||
.studio-note { border-left: 2px solid var(--line2); padding: 8px 0 8px 14px; color: var(--muted); margin: 14px 0; }
|
||||
.studio-note strong { color: var(--text); font-weight: 500; }
|
||||
.studio-note--warn { border-left-color: var(--accent); }
|
||||
.studio-note--bad { border-left-color: var(--bad); }
|
||||
.studio-note--ok { border-left-color: var(--ok); }
|
||||
.studio-errors { list-style: none; margin: 6px 0 0; padding: 0; font-size: 12.5px; color: var(--bad); }
|
||||
.studio-errors li { padding: 2px 0; font-family: var(--mono); }
|
||||
|
||||
/* course workspace */
|
||||
.studio-side-head { padding: 18px 16px 14px; border-bottom: 1px solid var(--line); }
|
||||
.studio-side-head h2 { font-size: 14.5px; margin: 0; }
|
||||
.studio-side-head .src { font-size: 12px; color: var(--faint); margin-top: 2px; }
|
||||
.studio-nav { list-style: none; margin: 0; padding: 6px 0; border-bottom: 1px solid var(--line); }
|
||||
.studio-nav button, .studio-nav a { display: grid; grid-template-columns: 1fr auto; gap: 6px; width: 100%; align-items: center; padding: 6px 16px; border: 0; background: none; text-align: left; font-size: 13px; color: var(--muted); text-decoration: none; }
|
||||
.studio-nav button:hover { background: color-mix(in srgb, var(--raised) 60%, transparent); color: var(--text); }
|
||||
.studio-nav button[aria-current="page"] { background: var(--raised); color: var(--head); box-shadow: inset 2px 0 0 var(--accent); }
|
||||
.studio-nav .mod { padding: 11px 16px 4px; font-size: 13.5px; color: var(--text); font-weight: 500; }
|
||||
.studio-nav .k { font-size: 11.5px; color: var(--faint); }
|
||||
.studio-nav .add { color: var(--faint); font-size: 12.5px; }
|
||||
|
||||
.studio-ws { display: flex; align-items: flex-start; margin: -44px -40px 0; }
|
||||
.studio-aside { position: sticky; top: var(--top); flex: none; width: var(--sidew); height: calc(100vh - var(--top)); overflow-y: auto; background: var(--side); border-right: 1px solid var(--line); overscroll-behavior: contain; }
|
||||
.studio-aside .src a { color: var(--muted); }
|
||||
.studio-content { flex: 1; min-width: 0; padding: 0 40px 64px; }
|
||||
.studio-bar { position: sticky; top: var(--top); z-index: 5; display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; margin: 0 -40px 26px; padding: 10px 40px; background: var(--ground); border-bottom: 1px solid var(--line); }
|
||||
.studio-bar .where { flex: 1; min-width: 200px; font-size: 12.5px; color: var(--muted); }
|
||||
.studio-bar .where b { color: var(--text); font-weight: 500; }
|
||||
|
||||
.studio-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 24px; align-items: start; }
|
||||
.studio-split textarea { min-height: 520px; }
|
||||
.studio-preview-wrap { border-left: 1px solid var(--line); padding-left: 24px; min-width: 0; }
|
||||
.studio-preview { --pf: var(--serif); font-size: 17px; }
|
||||
.studio-preview :where(h1, h2, h3) { font-family: var(--serif); color: var(--head); }
|
||||
.studio-preview h1 { font-size: 26px; line-height: 1.2; margin: 0 0 .6em; }
|
||||
.studio-preview pre.code { font-size: 13px; line-height: 1.6; background: var(--side); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; overflow-x: auto; color: var(--text); }
|
||||
.studio-preview :not(pre) > code { font-size: .8em; padding: .08em .32em; background: color-mix(in srgb, var(--raised) 75%, transparent); border-radius: 4px; color: var(--text); }
|
||||
.studio-preview blockquote { border-left: 2px solid var(--line2); margin: 0 0 1em; padding-left: 16px; color: var(--muted); }
|
||||
.studio-preview details { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); margin: 0 0 1.2em; padding: 0 2px; }
|
||||
.studio-preview summary { padding: 10px 0; font: 500 14px/1.4 var(--sans); color: var(--text); cursor: pointer; }
|
||||
.studio-preview img { max-width: 100%; }
|
||||
.studio-preview hr { border: 0; border-top: 1px solid var(--line); margin: 2em 0; }
|
||||
|
||||
.studio-section { padding: 18px 0; border-top: 1px solid var(--line); }
|
||||
.studio-section:first-of-type { border-top: 0; padding-top: 0; }
|
||||
|
||||
/* conflicts */
|
||||
.studio-conflict { border-top: 1px solid var(--line); padding: 16px 0; }
|
||||
.studio-sides { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin: 10px 0; }
|
||||
.studio-sides pre { margin: 0; max-height: 280px; overflow: auto; font-size: 12.5px; line-height: 1.55; background: var(--side); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; white-space: pre-wrap; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.studio-split, .studio-sides, .studio .grid2, .studio .grid3 { grid-template-columns: 1fr; }
|
||||
.studio-preview-wrap { border-left: 0; padding-left: 0; border-top: 1px solid var(--line); padding-top: 18px; }
|
||||
.studio-ws { flex-direction: column; margin: -44px -40px 0; }
|
||||
.studio-aside { position: static; width: 100%; height: auto; max-height: 50vh; border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.studio-content { padding: 0 16px 48px; width: 100%; }
|
||||
.studio-bar { margin: 0 -16px 20px; padding: 10px 16px; }
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, coursePath, rawUrl, type CourseState, type FileConflict, type FileWrite, type ManifestDoc } from "./api";
|
||||
|
||||
export type SaveOutcome = { ok: true } | { ok: false; conflicts?: FileConflict[]; error?: string };
|
||||
|
||||
/** What every editor gets: the loaded draft, a local manifest copy shared across editors, and save helpers. */
|
||||
export interface Workspace {
|
||||
courseId: string;
|
||||
state: CourseState;
|
||||
doc: ManifestDoc;
|
||||
docDirty: boolean;
|
||||
setDoc(update: (d: ManifestDoc) => void): void;
|
||||
/** One commit: optional files plus the local manifest when it has changes (or when `withManifest`). */
|
||||
save(opts: { files?: FileWrite[]; message: string; withManifest?: boolean; manifest?: ManifestDoc }): Promise<SaveOutcome>;
|
||||
upload(file: File, dir: string): Promise<{ ok: true; path: string } | { ok: false; error: string }>;
|
||||
reload(): Promise<void>;
|
||||
/** Bumped after every save so open file editors refetch their base hash. */
|
||||
version: number;
|
||||
resolveUrl(rel: string): string;
|
||||
}
|
||||
|
||||
export function clone<T>(x: T): T {
|
||||
return JSON.parse(JSON.stringify(x)) as T;
|
||||
}
|
||||
|
||||
/** A course file opened for editing, with the hash it was loaded at (for conflict detection on save). */
|
||||
export function useFile(courseId: string, path: string | undefined, version: number, initialIfMissing = "") {
|
||||
const [s, setS] = useState({ path: undefined as string | undefined, content: "", base: "", hash: null as string | null, loaded: false });
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
if (!path) { setS({ path, content: "", base: "", hash: null, loaded: true }); return; }
|
||||
setS((p) => ({ ...p, loaded: false }));
|
||||
api("GET", coursePath(courseId, `/file?path=${encodeURIComponent(path)}`)).then(({ status, data }) => {
|
||||
if (!live) return;
|
||||
if (status === 200) setS({ path, content: data.content, base: data.content, hash: data.hash, loaded: true });
|
||||
else setS({ path, content: initialIfMissing, base: "", hash: null, loaded: true });
|
||||
});
|
||||
return () => { live = false; };
|
||||
}, [courseId, path, version]);
|
||||
return {
|
||||
path,
|
||||
content: s.content,
|
||||
loaded: s.loaded && s.path === path,
|
||||
exists: s.hash !== null,
|
||||
dirty: s.content !== s.base,
|
||||
setContent: (c: string) => setS((p) => ({ ...p, content: c })),
|
||||
write: (): FileWrite | null => (path ? { path, content: s.content, baseHash: s.hash } : null),
|
||||
};
|
||||
}
|
||||
export type OpenFile = ReturnType<typeof useFile>;
|
||||
|
||||
export function useWorkspace(courseId: string) {
|
||||
const [state, setState] = useState<CourseState | null>(null);
|
||||
const [readOnly, setReadOnly] = useState<{ message: string; title: string; root: string } | null>(null);
|
||||
const [error, setError] = useState<{ status: number; error?: string } | null>(null);
|
||||
const [doc, setDocState] = useState<ManifestDoc | null>(null);
|
||||
const [docDirty, setDocDirty] = useState(false);
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const { status, data } = await api("GET", coursePath(courseId));
|
||||
if (status !== 200) { setError({ status, error: data?.error }); return; }
|
||||
setError(null);
|
||||
if (data.readOnly) { setReadOnly({ message: data.message, title: data.course.title, root: data.course.root }); return; }
|
||||
setState(data);
|
||||
setDocState(data.manifestRaw ? clone(data.manifestRaw) : null);
|
||||
setDocDirty(false);
|
||||
}, [courseId]);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
const ws: Workspace | null = state && doc ? {
|
||||
courseId, state, doc, docDirty, version,
|
||||
setDoc(update) {
|
||||
setDocState((d) => {
|
||||
const n = clone(d!);
|
||||
update(n);
|
||||
return n;
|
||||
});
|
||||
setDocDirty(true);
|
||||
},
|
||||
async save({ files = [], message, withManifest, manifest }) {
|
||||
const body: Record<string, unknown> = { message, files };
|
||||
if (manifest || withManifest === true || (withManifest !== false && docDirty)) {
|
||||
body.manifest = manifest ?? doc;
|
||||
body.manifestBaseHash = state.manifestHash;
|
||||
}
|
||||
const { status, data } = await api("PUT", coursePath(courseId, "/files"), body);
|
||||
if (status === 200) {
|
||||
setVersion((v) => v + 1);
|
||||
await reload();
|
||||
return { ok: true };
|
||||
}
|
||||
if (status === 409 && data?.conflicts) return { ok: false, conflicts: data.conflicts };
|
||||
return { ok: false, error: data?.error ?? `Save failed (${status})` };
|
||||
},
|
||||
async upload(file, dir) {
|
||||
const fd = new FormData();
|
||||
fd.set("file", file);
|
||||
fd.set("dir", dir);
|
||||
const { status, data } = await api("POST", coursePath(courseId, "/upload"), fd);
|
||||
if (status !== 200) return { ok: false, error: data?.error ?? `Upload failed (${status})` };
|
||||
return { ok: true, path: data.path };
|
||||
},
|
||||
reload,
|
||||
resolveUrl: (rel) => rawUrl(courseId, rel),
|
||||
} : null;
|
||||
|
||||
return { ws, state, readOnly, error, reload, setVersion };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { WebFeature } from "@stc/contracts";
|
||||
|
||||
/** Studio web routes. The pages call /api/studio, which allows only owners, authors and per-course authors. */
|
||||
export const studioWeb: WebFeature = {
|
||||
name: "studio",
|
||||
routes: [
|
||||
{ path: "/studio", element: () => import("./ui/StudioHome"), nav: { label: "Studio", area: "header" } },
|
||||
{ path: "/studio/:courseId", element: () => import("./ui/CourseStudio") },
|
||||
],
|
||||
};
|
||||
|
||||
export { setLessonPreview, type LessonPreviewProps, type LessonPreviewComponent } from "./ui/preview";
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { extGit } from "./helpers";
|
||||
import { REPO, setupApp } from "./app-helpers";
|
||||
|
||||
let env: Awaited<ReturnType<typeof setupApp>>;
|
||||
afterEach(() => env?.stop());
|
||||
|
||||
const lesson = (path: string) => `/api/studio/courses/c1/file?path=${encodeURIComponent(path)}`;
|
||||
|
||||
test("acceptance: create a course, add and edit a lesson, publish, a learner sees it", async () => {
|
||||
env = await setupApp();
|
||||
const { call, ctx } = env;
|
||||
expect((await call("ada", "POST", "/api/studio/courses", { id: "c1", title: "Course One" })).status).toBe(201);
|
||||
expect(ctx.courses.get("c1")?.status).toBe("ok");
|
||||
expect(ctx.courses.get("c1")?.root).toBe(join(env.dataDir, "worktrees/c1/published"));
|
||||
|
||||
// add a lesson (new file + manifest entry, one commit) and make the course public
|
||||
const st = (await call("ada", "GET", "/api/studio/courses/c1")).json;
|
||||
expect(st.readOnly).toBe(false);
|
||||
const m = structuredClone(st.manifestRaw);
|
||||
m.visibility = "public";
|
||||
m.modules[0].lessons.push({ id: "second", title: "Second lesson", type: "text", file: "lessons/second.md" });
|
||||
let r = await call("ada", "PUT", "/api/studio/courses/c1/files", {
|
||||
message: "Add second lesson",
|
||||
manifest: m, manifestBaseHash: st.manifestHash,
|
||||
files: [{ path: "lessons/second.md", content: "# Second\n\nFirst draft.\n", baseHash: null }],
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.json.commit).toBeTruthy();
|
||||
|
||||
// edit it
|
||||
const f = (await call("ada", "GET", lesson("lessons/second.md"))).json;
|
||||
expect(f.content).toContain("First draft.");
|
||||
r = await call("ada", "PUT", "/api/studio/courses/c1/files", { message: "Edit second", files: [{ path: "lessons/second.md", content: "# Second\n\nEdited text.\n", baseHash: f.hash }] });
|
||||
expect(r.status).toBe(200);
|
||||
|
||||
// drafts never reach learners
|
||||
expect(ctx.courses.get("c1")?.lessonIndex.has("second")).toBe(false);
|
||||
expect((await call(null, "GET", "/api/media/c1/lessons/second.md")).status).toBe(404);
|
||||
|
||||
// publish
|
||||
r = await call("ada", "POST", "/api/studio/courses/c1/publish");
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.json).toMatchObject({ ok: true, published: true });
|
||||
expect(await ctx.courses.readLessonMarkdown("c1", "second")).toBe("# Second\n\nEdited text.\n");
|
||||
const media = await call(null, "GET", "/api/media/c1/lessons/second.md");
|
||||
expect(media.status).toBe(200);
|
||||
expect(media.json).toContain("Edited text.");
|
||||
});
|
||||
|
||||
test("acceptance: a conflicting external commit is shown, not overwritten", async () => {
|
||||
env = await setupApp();
|
||||
const { call, ctx, feature } = env;
|
||||
await call("ada", "POST", "/api/studio/courses", { id: "c1", title: "Course One" });
|
||||
const f = (await call("ada", "GET", lesson("lessons/welcome.md"))).json;
|
||||
await call("ada", "PUT", "/api/studio/courses/c1/files", { message: "Studio edit", files: [{ path: "lessons/welcome.md", content: "# Studio\n", baseHash: f.hash }] });
|
||||
|
||||
const pub = join(env.dataDir, "worktrees/c1/published");
|
||||
writeFileSync(join(pub, "lessons/welcome.md"), "# External\n");
|
||||
await extGit(pub, "commit", "-qam", "Edited in VS Code");
|
||||
expect(await feature.watcher.check()).toEqual(["c1"]);
|
||||
expect(await ctx.courses.readLessonMarkdown("c1", "welcome")).toBe("# External\n");
|
||||
|
||||
const st = (await call("ada", "GET", "/api/studio/courses/c1")).json;
|
||||
expect(st.status.behind).toBe(1);
|
||||
|
||||
const r = await call("ada", "POST", "/api/studio/courses/c1/publish");
|
||||
expect(r.status).toBe(409);
|
||||
expect(r.json).toEqual({ ok: false, reason: "conflict", conflicts: ["lessons/welcome.md"] });
|
||||
expect(await ctx.courses.readLessonMarkdown("c1", "welcome")).toBe("# External\n");
|
||||
|
||||
const u = await call("ada", "POST", "/api/studio/courses/c1/update-draft", { from: "published" });
|
||||
expect(u.status).toBe(409);
|
||||
const c = (await call("ada", "GET", "/api/studio/courses/c1/conflicts")).json;
|
||||
expect(c.files[0]).toMatchObject({ path: "lessons/welcome.md", ours: "# Studio\n", theirs: "# External\n" });
|
||||
expect((await call("ada", "POST", "/api/studio/courses/c1/resolve", { files: [{ path: "lessons/welcome.md", content: "# Studio + External\n" }] })).json.remaining).toEqual([]);
|
||||
expect((await call("ada", "POST", "/api/studio/courses/c1/publish")).status).toBe(200);
|
||||
expect(await ctx.courses.readLessonMarkdown("c1", "welcome")).toBe("# Studio + External\n");
|
||||
});
|
||||
|
||||
test("overlay and other outside courses are read-only", async () => {
|
||||
env = await setupApp([resolve(REPO, "fixtures/courses")]);
|
||||
const list = (await env.call("ada", "GET", "/api/studio/courses")).json;
|
||||
expect(list.courses).toContainEqual(expect.objectContaining({ id: "sample", managed: false, message: "This course is managed outside STCourses" }));
|
||||
const one = await env.call("ada", "GET", "/api/studio/courses/sample");
|
||||
expect(one.json).toMatchObject({ readOnly: true, message: "This course is managed outside STCourses" });
|
||||
const w = await env.call("ada", "PUT", "/api/studio/courses/sample/files", { message: "x", files: [{ path: "a.md", content: "x" }] });
|
||||
expect(w.status).toBe(403);
|
||||
expect((await env.call("ada", "POST", "/api/studio/courses", { id: "sample", title: "Dup" })).status).toBe(409);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { resolve } from "node:path";
|
||||
import type { AuthUser, ServerContext } from "@stc/contracts";
|
||||
import { createApp } from "@stc/core";
|
||||
import { createCourseRepository } from "@stc/course-format";
|
||||
import { connect, migrate } from "@stc/db";
|
||||
import { studioFeature } from "../src/server/feature";
|
||||
import { studioCourseDirs } from "../src/server/paths";
|
||||
import { tempDir } from "./helpers";
|
||||
|
||||
export const REPO = resolve(import.meta.dir, "../../..");
|
||||
export const users: Record<string, AuthUser> = {
|
||||
ada: { id: "u-ada", email: "ada@example.org", name: "Ada Lovelace", roles: ["author"], groups: [] },
|
||||
owner: { id: "u-own", email: "own@example.org", name: "Olive Owner", roles: ["owner"], groups: [] },
|
||||
lea: { id: "u-lea", email: "lea@example.org", name: "Lea Learner", roles: ["learner"], groups: [] },
|
||||
};
|
||||
|
||||
/** A server context like main.ts builds, with header-selected users (`x-user: ada`). */
|
||||
export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite://:memory:", prepare?: (dataDir: string) => void) {
|
||||
const dataDir = tempDir();
|
||||
prepare?.(dataDir);
|
||||
let dropSchema = async () => {};
|
||||
if (dbUrl.startsWith("postgres")) {
|
||||
// a private schema per test, like packages/db's forEachDb
|
||||
const schema = `t_${Bun.randomUUIDv7().replaceAll("-", "")}`;
|
||||
const admin = await connect(dbUrl);
|
||||
await admin.sql.unsafe(`CREATE SCHEMA ${schema}`);
|
||||
const u = new URL(dbUrl);
|
||||
u.searchParams.set("options", `-c search_path=${schema}`);
|
||||
dbUrl = u.toString();
|
||||
dropSchema = async () => { await admin.sql.unsafe(`DROP SCHEMA ${schema} CASCADE`); await admin.close(); };
|
||||
}
|
||||
const db = await connect(dbUrl);
|
||||
await migrate(db);
|
||||
const config = { port: 0, databaseUrl: "sqlite://:memory:", dataDir, courseDirs: extraCourseDirs, secret: "t", publicUrl: "http://x" };
|
||||
const courses = await createCourseRepository(studioCourseDirs(config));
|
||||
const ctx = {
|
||||
config, db, courses, jobs: null as never,
|
||||
auth: { userFromRequest: async (req: Request) => users[req.headers.get("x-user") ?? ""] ?? null },
|
||||
} satisfies ServerContext;
|
||||
const feature = studioFeature(ctx, { watch: false });
|
||||
const app = createApp(ctx, [feature]);
|
||||
const call = async (as: string | null, method: string, path: string, body?: unknown) => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (as) headers["x-user"] = as;
|
||||
let payload: BodyInit | undefined;
|
||||
if (body instanceof FormData) payload = body;
|
||||
else if (body !== undefined) { headers["content-type"] = "application/json"; payload = JSON.stringify(body); }
|
||||
const r = await app.fetch(new Request(`http://x${path}`, { method, headers, body: payload }));
|
||||
const text = await r.text();
|
||||
let json: any = null;
|
||||
try { json = JSON.parse(text); } catch { json = text; }
|
||||
return { status: r.status, json };
|
||||
};
|
||||
return { ctx, db, dataDir, feature, app, call, stop: async () => { feature.stop(); await db.close(); await dropSchema(); } };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createDraftWriter } from "../src/server/draft-writer";
|
||||
import { ada, extGit, newStore } from "./helpers";
|
||||
|
||||
test("writeDraftFiles commits on draft/<author>, never on the published branch", async () => {
|
||||
const { dataDir, store } = await newStore();
|
||||
await store.createCourse({ id: "c1", title: "C" }, ada);
|
||||
const writer = createDraftWriter({ config: { dataDir } }, { identity: async (id) => (id === ada.id ? ada : null) });
|
||||
const r = await writer.writeDraftFiles("c1", ada.id, [{ path: "lessons/generated.md", content: "# Generated\n" }], "AI draft: module 2");
|
||||
expect(r.branch).toBe("draft/u-ada");
|
||||
const draft = store.paths("c1").draft(ada.id);
|
||||
expect(await extGit(draft, "rev-parse", "HEAD")).toBe(r.commit);
|
||||
expect(await extGit(draft, "log", "-1", "--format=%an|%s")).toBe("Ada Lovelace|AI draft: module 2");
|
||||
expect(readFileSync(join(draft, "lessons/generated.md"), "utf8")).toBe("# Generated\n");
|
||||
expect(existsSync(join(store.paths("c1").published, "lessons/generated.md"))).toBe(false);
|
||||
});
|
||||
|
||||
test("unknown identity falls back to the author id; unmanaged course and bad paths throw", async () => {
|
||||
const { dataDir, store } = await newStore();
|
||||
await store.createCourse({ id: "c1", title: "C" }, ada);
|
||||
const writer = createDraftWriter({ config: { dataDir } });
|
||||
const r = await writer.writeDraftFiles("c1", "u-x", [{ path: "a.md", content: "a" }], "m");
|
||||
expect(await extGit(store.paths("c1").draft("u-x"), "log", "-1", "--format=%an <%ae>")).toBe("u-x <u-x@users.stcourses.invalid>");
|
||||
expect(r.branch).toBe("draft/u-x");
|
||||
await expect(writer.writeDraftFiles("pure-snake", "u-x", [{ path: "a.md", content: "a" }], "m")).rejects.toThrow(/not managed/);
|
||||
await expect(writer.writeDraftFiles("c1", "u-x", [{ path: "../a.md", content: "a" }], "m")).rejects.toThrow(/path/);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { setupApp } from "./app-helpers";
|
||||
|
||||
let env: Awaited<ReturnType<typeof setupApp>>;
|
||||
afterEach(() => env?.stop());
|
||||
|
||||
test("anonymous gets 401, learners 403", async () => {
|
||||
env = await setupApp();
|
||||
expect((await env.call(null, "GET", "/api/studio/courses")).status).toBe(401);
|
||||
expect((await env.call("lea", "GET", "/api/studio/courses")).status).toBe(403);
|
||||
expect((await env.call("lea", "POST", "/api/studio/courses", { id: "x", title: "X" })).status).toBe(403);
|
||||
});
|
||||
|
||||
const DBS: [string, string][] = [["sqlite", "sqlite://:memory:"], ...(process.env.STC_TEST_PG_URL ? [["postgres", process.env.STC_TEST_PG_URL] as [string, string]] : [])];
|
||||
|
||||
test.each(DBS)("creating a course records the creator in course_authors; a per-course author without the role can edit it [%s]", async (_name, url) => {
|
||||
env = await setupApp([], url);
|
||||
expect((await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" })).status).toBe(201);
|
||||
const rows = await env.db.sql`SELECT course_id, user_id FROM course_authors`;
|
||||
expect(rows.map((r: any) => [r.course_id, r.user_id])).toEqual([["c1", "u-ada"]]);
|
||||
await env.db.sql`INSERT INTO course_authors (course_id, user_id) VALUES (${"c1"}, ${"u-lea"})`;
|
||||
const list = (await env.call("lea", "GET", "/api/studio/courses")).json;
|
||||
expect(list.courses.map((c: any) => c.id)).toEqual(["c1"]);
|
||||
expect((await env.call("lea", "GET", "/api/studio/courses/c1")).status).toBe(200);
|
||||
expect((await env.call("lea", "POST", "/api/studio/courses", { id: "c2", title: "C2" })).status).toBe(403);
|
||||
});
|
||||
|
||||
test("bad input: invalid id, missing title, bad JSON, bad paths, stale base hash", async () => {
|
||||
env = await setupApp();
|
||||
expect((await env.call("ada", "POST", "/api/studio/courses", { id: "Bad Id", title: "x" })).status).toBe(400);
|
||||
expect((await env.call("ada", "POST", "/api/studio/courses", { id: "c1" })).status).toBe(400);
|
||||
await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" });
|
||||
expect((await env.call("ada", "PUT", "/api/studio/courses/c1/files", { message: "x", files: [{ path: "../x", content: "x" }] })).status).toBe(400);
|
||||
const r = await env.call("ada", "PUT", "/api/studio/courses/c1/files", { message: "x", files: [{ path: "lessons/welcome.md", content: "x", baseHash: "stale" }] });
|
||||
expect(r.status).toBe(409);
|
||||
expect(r.json.conflicts[0].path).toBe("lessons/welcome.md");
|
||||
expect((await env.call("ada", "GET", "/api/studio/courses/nope")).status).toBe(404);
|
||||
expect((await env.call("ada", "GET", "/api/studio/courses/c1/file?path=../../etc/passwd")).status).toBe(404);
|
||||
});
|
||||
|
||||
test("manifest validation errors come from @stc/course-format", async () => {
|
||||
env = await setupApp();
|
||||
await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" });
|
||||
const st = (await env.call("ada", "GET", "/api/studio/courses/c1")).json;
|
||||
expect(st.manifestErrors).toEqual([]);
|
||||
const bad = { ...st.manifestRaw, accent: "orange", modules: [{ id: "m", title: "M", lessons: [{ id: "x", title: "X", type: "text" }] }] };
|
||||
const v = (await env.call("ada", "POST", "/api/studio/courses/c1/validate", { manifest: bad })).json;
|
||||
expect(v.errors).toEqual(expect.arrayContaining([expect.stringContaining("accent"), expect.stringContaining("file is required")]));
|
||||
// saving an invalid draft is allowed (it is a draft) but publishing is refused with diagnostics
|
||||
await env.call("ada", "PUT", "/api/studio/courses/c1/files", { message: "wip", manifest: bad, manifestBaseHash: st.manifestHash, files: [] });
|
||||
const st2 = (await env.call("ada", "GET", "/api/studio/courses/c1")).json;
|
||||
expect(st2.manifestErrors.length).toBeGreaterThan(0);
|
||||
const p = await env.call("ada", "POST", "/api/studio/courses/c1/publish");
|
||||
expect(p.status).toBe(422);
|
||||
expect(p.json.reason).toBe("invalid");
|
||||
});
|
||||
|
||||
test("upload an attachment and serve it back from the draft", async () => {
|
||||
env = await setupApp();
|
||||
await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" });
|
||||
const fd = new FormData();
|
||||
fd.set("file", new File([new Uint8Array([37, 80, 68, 70])], "Æble slides.pdf", { type: "application/pdf" }));
|
||||
fd.set("dir", "media");
|
||||
const r = await env.call("ada", "POST", "/api/studio/courses/c1/upload", fd);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.json.path).toBe("media/Æble slides.pdf");
|
||||
const raw = await env.app.fetch(new Request(`http://x/api/studio/courses/c1/raw/${encodeURIComponent("media/Æble slides.pdf")}`, { headers: { "x-user": "ada" } }));
|
||||
expect(raw.status).toBe(200);
|
||||
expect(raw.headers.get("content-type")).toBe("application/pdf");
|
||||
expect((await env.call("lea", "GET", `/api/studio/courses/c1/raw/media/x`)).status).toBe(403);
|
||||
});
|
||||
|
||||
test("remote config and sync without a remote", async () => {
|
||||
env = await setupApp();
|
||||
await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" });
|
||||
expect((await env.call("ada", "POST", "/api/studio/courses/c1/sync")).status).toBe(400);
|
||||
expect((await env.call("ada", "PUT", "/api/studio/courses/c1/remote", { url: "/tmp/nowhere.git" })).status).toBe(400);
|
||||
expect((await env.call("ada", "PUT", "/api/studio/courses/c1/remote", { url: "https://nowhere.invalid/x.git" })).status).toBe(200);
|
||||
expect((await env.call("ada", "GET", "/api/studio/courses/c1")).json.status.remote).toBe("https://nowhere.invalid/x.git");
|
||||
expect((await env.call("ada", "POST", "/api/studio/courses/c1/sync")).status).toBe(502);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { $ } from "bun";
|
||||
import { createStudioStore, type Identity } from "../src/server/store";
|
||||
|
||||
export const ada: Identity & { id: string } = { id: "u-ada", name: "Ada Lovelace", email: "ada@example.org" };
|
||||
export const bob: Identity & { id: string } = { id: "u-bob", name: "Bob Ext", email: "bob@example.org" };
|
||||
|
||||
export function tempDir(prefix = "stc-studio-"): string {
|
||||
return realpathSync(mkdtempSync(join(tmpdir(), prefix)));
|
||||
}
|
||||
|
||||
export async function newStore(opts: { allowLocalRemotes?: boolean } = {}) {
|
||||
const dataDir = tempDir();
|
||||
const store = createStudioStore({ dataDir, ...opts });
|
||||
return { dataDir, store };
|
||||
}
|
||||
|
||||
/** Runs git as an outside tool (VS Code, a terminal) would: plain identity, in the given dir. */
|
||||
export async function extGit(cwd: string, ...args: string[]): Promise<string> {
|
||||
const r = await $`git -c user.name=${bob.name} -c user.email=${bob.email} -c commit.gpgsign=false -c core.hooksPath=/dev/null ${args}`.cwd(cwd).quiet().nothrow();
|
||||
if (r.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${r.stderr.toString()}`);
|
||||
return r.stdout.toString().trim();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderMarkdown } from "../src/ui/markdown";
|
||||
|
||||
test("headings, paragraphs, emphasis, inline code", () => {
|
||||
expect(renderMarkdown("# Title\n\nSome **bold** and *em* and `x < y`.\nSecond line.")).toBe(
|
||||
'<h1 id="title">Title</h1>\n<p>Some <strong>bold</strong> and <em>em</em> and <code>x < y</code>.\nSecond line.</p>',
|
||||
);
|
||||
});
|
||||
|
||||
test("raw HTML is escaped, except the details/summary hint blocks", () => {
|
||||
expect(renderMarkdown("<script>alert(1)</script>")).toBe("<p><script>alert(1)</script></p>");
|
||||
const h = renderMarkdown("<details>\n<summary>Exercise 1 — nudge</summary>\n\nTry **this**.\n\n</details>");
|
||||
expect(h).toMatch(/<details>\s*<summary>Exercise 1 — nudge<\/summary>/);
|
||||
expect(h).toContain("<p>Try <strong>this</strong>.</p>");
|
||||
expect(h).toContain("</details>");
|
||||
expect(renderMarkdown("<summary onclick=x>a</summary>")).not.toContain("<summary onclick");
|
||||
});
|
||||
|
||||
test("fenced code keeps content verbatim and escaped", () => {
|
||||
expect(renderMarkdown("```js\nconst a = '<b>';\n# not a heading\n```")).toBe(
|
||||
'<pre class="code"><code data-lang="js">const a = \'<b>\';\n# not a heading</code></pre>',
|
||||
);
|
||||
});
|
||||
|
||||
test("lists, quotes, rules", () => {
|
||||
expect(renderMarkdown("- a\n- b\n\n1. one\n2. two")).toBe("<ul><li>a</li><li>b</li></ul>\n<ol><li>one</li><li>two</li></ol>");
|
||||
expect(renderMarkdown("> quoted\n\n---")).toBe("<blockquote><p>quoted</p></blockquote>\n<hr>");
|
||||
});
|
||||
|
||||
test("links: safe schemes only; images resolved through the given resolver", () => {
|
||||
expect(renderMarkdown("[ok](https://x.org/a?b=1&c=2)")).toBe('<p><a href="https://x.org/a?b=1&c=2">ok</a></p>');
|
||||
expect(renderMarkdown("[bad](javascript:alert(1))")).toBe("<p>bad</p>");
|
||||
expect(renderMarkdown("", { resolveUrl: (u) => `/raw/${encodeURIComponent(u)}` })).toBe('<p><img src="/raw/img%2Fa%20b.png" alt="d"></p>');
|
||||
});
|
||||
|
||||
test("front matter is not rendered", () => {
|
||||
expect(renderMarkdown("---\ntitle: x\n---\n# H")).toBe('<h1 id="h">H</h1>');
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ada, bob, extGit, newStore, tempDir } from "./helpers";
|
||||
|
||||
async function courseWithEdit(text = "# Draft text\n", opts: { allowLocalRemotes?: boolean } = {}) {
|
||||
const s = await newStore(opts);
|
||||
await s.store.createCourse({ id: "c1", title: "C" }, ada);
|
||||
const cur = (await s.store.readFile("c1", ada.id, "lessons/welcome.md"))!;
|
||||
await s.store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: text, baseHash: cur.hash }], "Draft edit");
|
||||
return { ...s, pub: s.store.paths("c1").published };
|
||||
}
|
||||
|
||||
describe("publish", () => {
|
||||
test("merges the draft into main; the draft is level afterwards", async () => {
|
||||
const { store, pub } = await courseWithEdit();
|
||||
const r = await store.publish("c1", ada);
|
||||
expect(r).toMatchObject({ ok: true, published: true });
|
||||
expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("# Draft text\n");
|
||||
expect(await extGit(pub, "log", "-1", "--format=%an|%s")).toBe("Ada Lovelace|Publish draft/u-ada");
|
||||
const st = await store.status("c1", ada.id);
|
||||
expect([st.ahead, st.behind]).toEqual([0, 0]);
|
||||
expect(await store.publish("c1", ada)).toMatchObject({ ok: true, published: false });
|
||||
});
|
||||
|
||||
test("a conflicting external commit on main is reported and main keeps the external content", async () => {
|
||||
const { store, pub } = await courseWithEdit("# Studio version\n");
|
||||
writeFileSync(join(pub, "lessons/welcome.md"), "# External version\n");
|
||||
await extGit(pub, "commit", "-qam", "External edit");
|
||||
const head = await extGit(pub, "rev-parse", "HEAD");
|
||||
const r = await store.publish("c1", ada);
|
||||
expect(r).toEqual({ ok: false, reason: "conflict", conflicts: ["lessons/welcome.md"] });
|
||||
expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("# External version\n");
|
||||
expect(await extGit(pub, "rev-parse", "HEAD")).toBe(head);
|
||||
expect(await extGit(pub, "status", "--porcelain")).toBe("");
|
||||
expect((await store.status("c1", ada.id)).behind).toBe(1);
|
||||
});
|
||||
|
||||
test("uncommitted edits in the published worktree block publishing", async () => {
|
||||
const { store, pub } = await courseWithEdit();
|
||||
writeFileSync(join(pub, "lessons/welcome.md"), "half-typed in an editor");
|
||||
const r = await store.publish("c1", ada);
|
||||
expect(r).toEqual({ ok: false, reason: "dirty", files: ["lessons/welcome.md"] });
|
||||
expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("half-typed in an editor");
|
||||
});
|
||||
|
||||
test("an invalid manifest blocks publishing with diagnostics", async () => {
|
||||
const { store } = await courseWithEdit();
|
||||
const m = (await store.readFile("c1", ada.id, "stcourse.yaml"))!;
|
||||
await store.saveFiles("c1", ada, [{ path: "stcourse.yaml", content: m.content.replace("lessons/welcome.md", "lessons/missing.md"), baseHash: m.hash }], "break");
|
||||
const r = await store.publish("c1", ada);
|
||||
expect(r.ok).toBe(false);
|
||||
if (r.ok || r.reason !== "invalid") throw new Error("expected invalid");
|
||||
expect(r.diagnostics[0]!.message).toContain("lessons/missing.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("update draft and conflict resolution", () => {
|
||||
test("conflicts are shown with both sides, resolved by the author, then publish succeeds", async () => {
|
||||
const { store, pub } = await courseWithEdit("# Studio version\n");
|
||||
writeFileSync(join(pub, "lessons/welcome.md"), "# External version\n");
|
||||
await extGit(pub, "commit", "-qam", "External edit");
|
||||
const u = await store.updateDraft("c1", ada);
|
||||
expect(u).toEqual({ ok: false, reason: "conflict", conflicts: ["lessons/welcome.md"] });
|
||||
const st = await store.status("c1", ada.id);
|
||||
expect(st.merging).toBe(true);
|
||||
expect(st.conflicts).toEqual(["lessons/welcome.md"]);
|
||||
await expect(store.saveFiles("c1", ada, [{ path: "x.md", content: "x" }], "x")).rejects.toThrow(/merge/);
|
||||
const [c] = await store.conflicts("c1", ada.id);
|
||||
expect(c).toMatchObject({ path: "lessons/welcome.md", ours: "# Studio version\n", theirs: "# External version\n" });
|
||||
expect(c!.working).toContain("<<<<<<<");
|
||||
const res = await store.resolve("c1", ada, [{ path: "lessons/welcome.md", content: "# Both versions\n" }]);
|
||||
expect(res.remaining).toEqual([]);
|
||||
expect(res.commit).toBeTruthy();
|
||||
expect(await store.publish("c1", ada)).toMatchObject({ ok: true, published: true });
|
||||
expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("# Both versions\n");
|
||||
});
|
||||
|
||||
test("abort returns the draft to its pre-merge state", async () => {
|
||||
const { store, pub } = await courseWithEdit("# Studio version\n");
|
||||
writeFileSync(join(pub, "lessons/welcome.md"), "# External version\n");
|
||||
await extGit(pub, "commit", "-qam", "External edit");
|
||||
await store.updateDraft("c1", ada);
|
||||
await store.abortMerge("c1", ada.id);
|
||||
const st = await store.status("c1", ada.id);
|
||||
expect(st.merging).toBe(false);
|
||||
expect((await store.readFile("c1", ada.id, "lessons/welcome.md"))!.content).toBe("# Studio version\n");
|
||||
});
|
||||
|
||||
test("a non-conflicting external commit merges cleanly into the draft", async () => {
|
||||
const { store, pub } = await courseWithEdit();
|
||||
writeFileSync(join(pub, "extra.md"), "external\n");
|
||||
await extGit(pub, "add", "extra.md");
|
||||
await extGit(pub, "commit", "-qm", "External add");
|
||||
expect((await store.updateDraft("c1", ada)).ok).toBe(true);
|
||||
expect((await store.readFile("c1", ada.id, "extra.md"))!.content).toBe("external\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sync", () => {
|
||||
test("no remote: an error, nothing happens", async () => {
|
||||
const { store } = await courseWithEdit();
|
||||
expect(await store.sync("c1", ada)).toMatchObject({ ok: false, reason: "no-remote" });
|
||||
});
|
||||
|
||||
test("push to an empty remote, pull a remote commit, report a conflicting divergence", async () => {
|
||||
// local bare remotes need the test-only allowLocalRemotes switch; the HTTP feature never sets it
|
||||
const { store, pub } = await courseWithEdit("# Draft text\n", { allowLocalRemotes: true });
|
||||
await store.publish("c1", ada);
|
||||
const remote = join(tempDir(), "remote.git");
|
||||
await extGit(tempDir(), "init", "-q", "--bare", "-b", "main", remote);
|
||||
await store.setRemote("c1", remote);
|
||||
expect(await store.remote("c1")).toBe(remote);
|
||||
expect(await store.sync("c1", ada)).toMatchObject({ ok: true, pushed: true, pulled: false });
|
||||
expect(await extGit(remote, "rev-parse", "main")).toBe(await extGit(pub, "rev-parse", "HEAD"));
|
||||
|
||||
// someone else pushes to the remote
|
||||
const other = join(tempDir(), "other");
|
||||
await extGit(tempDir(), "clone", "-q", remote, other);
|
||||
writeFileSync(join(other, "lessons/welcome.md"), "# Remote version\n");
|
||||
await extGit(other, "commit", "-qam", "Remote edit");
|
||||
await extGit(other, "push", "-q", "origin", "main");
|
||||
expect(await store.sync("c1", ada)).toMatchObject({ ok: true, pulled: true, pushed: false });
|
||||
expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("# Remote version\n");
|
||||
|
||||
// diverge with a conflict
|
||||
writeFileSync(join(other, "lessons/welcome.md"), "# Remote again\n");
|
||||
await extGit(other, "commit", "-qam", "Remote edit 2");
|
||||
await extGit(other, "push", "-q", "origin", "main");
|
||||
await store.updateDraft("c1", ada);
|
||||
const cur = (await store.readFile("c1", ada.id, "lessons/welcome.md"))!;
|
||||
await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "# Local again\n", baseHash: cur.hash }], "local");
|
||||
expect((await store.publish("c1", ada)).ok).toBe(true);
|
||||
const head = await extGit(pub, "rev-parse", "HEAD");
|
||||
expect(await store.sync("c1", ada)).toEqual({ ok: false, reason: "conflict", conflicts: ["lessons/welcome.md"] });
|
||||
expect(await extGit(pub, "rev-parse", "HEAD")).toBe(head);
|
||||
expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("# Local again\n");
|
||||
|
||||
// the author resolves via the draft, then Sync goes through
|
||||
expect(await store.updateDraft("c1", ada, "remote")).toMatchObject({ ok: false, reason: "conflict" });
|
||||
await store.resolve("c1", ada, [{ path: "lessons/welcome.md", content: "# Merged\n" }]);
|
||||
expect((await store.publish("c1", ada)).ok).toBe(true);
|
||||
expect(await store.sync("c1", ada)).toMatchObject({ ok: true, pushed: true });
|
||||
expect(await extGit(remote, "show", "main:lessons/welcome.md")).toBe("# Merged");
|
||||
void bob;
|
||||
});
|
||||
});
|
||||
|
||||
test("ext:: remotes cannot run commands, even with local remotes allowed for tests", async () => {
|
||||
const { store } = await courseWithEdit("# x\n", { allowLocalRemotes: true });
|
||||
const marker = join(tempDir(), "pwned");
|
||||
await expect(store.setRemote("c1", `ext::touch%s${marker}`)).rejects.toThrow(/remote/);
|
||||
expect(await Bun.file(marker).exists()).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { isAllowedRemote, ownerRequiredFor } from "../src/server/store";
|
||||
import { ada, extGit, newStore, tempDir } from "./helpers";
|
||||
import { setupApp } from "./app-helpers";
|
||||
|
||||
describe("C1: writes never follow symlinks", () => {
|
||||
test("a dangling symlink is refused for save and resolve; the outside target is never created", async () => {
|
||||
const { store } = await newStore();
|
||||
await store.createCourse({ id: "c1", title: "C" }, ada);
|
||||
const draft = await store.ensureDraft("c1", ada.id);
|
||||
const outside = join(tempDir(), "pwned.txt");
|
||||
symlinkSync(outside, join(draft, "evil.md"));
|
||||
await expect(store.saveFiles("c1", ada, [{ path: "evil.md", content: "x" }], "x")).rejects.toThrow(/symlink/);
|
||||
await expect(store.saveFiles("c1", ada, [{ path: "evil.md", content: "x", baseHash: null }], "x")).rejects.toThrow(/symlink/);
|
||||
expect(existsSync(outside)).toBe(false);
|
||||
});
|
||||
|
||||
test("a symlinked parent directory is refused", async () => {
|
||||
const { store } = await newStore();
|
||||
await store.createCourse({ id: "c1", title: "C" }, ada);
|
||||
const draft = await store.ensureDraft("c1", ada.id);
|
||||
const outsideDir = tempDir();
|
||||
symlinkSync(outsideDir, join(draft, "sub"));
|
||||
await expect(store.saveFiles("c1", ada, [{ path: "sub/x.md", content: "x" }], "x")).rejects.toThrow(/symlink/);
|
||||
await expect(store.saveFiles("c1", ada, [{ path: "sub/deeper/x.md", content: "x" }], "x")).rejects.toThrow(/symlink/);
|
||||
expect(existsSync(join(outsideDir, "x.md"))).toBe(false);
|
||||
expect(existsSync(join(outsideDir, "deeper"))).toBe(false);
|
||||
});
|
||||
|
||||
test("a symlink pulled in from a remote merge cannot be written through; deleting the link leaves the target", async () => {
|
||||
const { store } = await newStore({ allowLocalRemotes: true });
|
||||
await store.createCourse({ id: "c1", title: "C" }, ada);
|
||||
const remote = join(tempDir(), "remote.git");
|
||||
await extGit(tempDir(), "init", "-q", "--bare", "-b", "main", remote);
|
||||
await store.setRemote("c1", remote);
|
||||
await store.sync("c1", ada); // push main
|
||||
const outsideDir = tempDir();
|
||||
const target = join(outsideDir, "target.md");
|
||||
writeFileSync(target, "outside\n");
|
||||
const other = join(tempDir(), "other");
|
||||
await extGit(tempDir(), "clone", "-q", remote, other);
|
||||
symlinkSync(target, join(other, "notes.md"));
|
||||
symlinkSync(join(outsideDir, "new.md"), join(other, "dangling.md"));
|
||||
await extGit(other, "add", "notes.md", "dangling.md");
|
||||
await extGit(other, "commit", "-qm", "plant symlinks");
|
||||
await extGit(other, "push", "-q", "origin", "main");
|
||||
expect((await store.updateDraft("c1", ada, "remote")).ok).toBe(true);
|
||||
await expect(store.saveFiles("c1", ada, [{ path: "notes.md", content: "pwned" }], "x")).rejects.toThrow(/symlink/);
|
||||
await expect(store.saveFiles("c1", ada, [{ path: "dangling.md", content: "pwned" }], "x")).rejects.toThrow(/symlink/);
|
||||
expect(readFileSync(target, "utf8")).toBe("outside\n");
|
||||
expect(existsSync(join(outsideDir, "new.md"))).toBe(false);
|
||||
expect((await store.saveFiles("c1", ada, [{ path: "notes.md", delete: true }], "rm link")).ok).toBe(true);
|
||||
expect(readFileSync(target, "utf8")).toBe("outside\n");
|
||||
});
|
||||
|
||||
test("resolve refuses to write through a symlink", async () => {
|
||||
const { store } = await newStore();
|
||||
await store.createCourse({ id: "c1", title: "C" }, ada);
|
||||
const pub = store.paths("c1").published;
|
||||
const cur = (await store.readFile("c1", ada.id, "lessons/welcome.md"))!;
|
||||
await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "# mine\n", baseHash: cur.hash }], "mine");
|
||||
writeFileSync(join(pub, "lessons/welcome.md"), "# theirs\n");
|
||||
await extGit(pub, "commit", "-qam", "theirs");
|
||||
await store.updateDraft("c1", ada);
|
||||
const outside = join(tempDir(), "p.txt");
|
||||
symlinkSync(outside, join(store.paths("c1").draft(ada.id), "link.md"));
|
||||
await expect(store.resolve("c1", ada, [{ path: "link.md", content: "x" }])).rejects.toThrow(/symlink/);
|
||||
expect(existsSync(outside)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("I1: remotes", () => {
|
||||
test.each([
|
||||
"/tmp/x.git", "../x.git", "x.git", "file:///tmp/x.git", "ext::sh -c touch% /tmp/p", "fd::3", "http://host/x.git", "git://host/x.git",
|
||||
"-uoption", "https://", "https:///x", "git@:x", "git@host:-x", "ssh://host", "ssh://-oProxyCommand=x/y", "https://host/x y",
|
||||
])("rejects %p", async (url) => {
|
||||
expect(isAllowedRemote(url)).toBe(false);
|
||||
const { store } = await newStore();
|
||||
await store.createCourse({ id: "c1", title: "C" }, ada);
|
||||
await expect(store.setRemote("c1", url)).rejects.toThrow();
|
||||
expect(await store.remote("c1")).toBeNull();
|
||||
});
|
||||
|
||||
test.each(["https://github.com/a/b.git", "https://user:tok@git.example.org:8443/a/b.git", "ssh://git@host.example:22/a/b.git", "git@github.com:a/b.git"])("accepts %p", (url) => {
|
||||
expect(isAllowedRemote(url)).toBe(true);
|
||||
});
|
||||
|
||||
test("a remote configured outside the studio is re-checked before sync and update", async () => {
|
||||
const { store } = await newStore();
|
||||
await store.createCourse({ id: "c1", title: "C" }, ada);
|
||||
const remote = join(tempDir(), "remote.git");
|
||||
await extGit(tempDir(), "init", "-q", "--bare", "-b", "main", remote);
|
||||
await extGit(store.paths("c1").bare, "remote", "add", "origin", remote);
|
||||
expect(await store.sync("c1", ada)).toMatchObject({ ok: false, reason: "no-remote" });
|
||||
expect(await store.updateDraft("c1", ada, "remote")).toMatchObject({ ok: false, reason: "no-remote" });
|
||||
expect(await extGit(remote, "for-each-ref")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("I3: owner-only visibility", () => {
|
||||
const imported = "schema: 1\nid: c1\ntitle: C\nvisibility: restricted\nsource: { kind: packt-video }\nmodules: []\n";
|
||||
test("rule", () => {
|
||||
expect(ownerRequiredFor(imported, imported)).toBeNull();
|
||||
expect(ownerRequiredFor(imported, imported.replace("restricted", "public"))).toMatch(/owner/);
|
||||
expect(ownerRequiredFor(imported, imported.replace("restricted", "signed-in").replace("packt-video", "authored"))).toMatch(/owner/);
|
||||
expect(ownerRequiredFor(imported, imported.replace("source: { kind: packt-video }\n", ""))).toMatch(/source.kind/);
|
||||
const authored = imported.replace("packt-video", "authored");
|
||||
expect(ownerRequiredFor(authored, authored.replace("restricted", "public"))).toBeNull();
|
||||
expect(ownerRequiredFor(authored, authored.replace("authored", "pdf-deck"))).toMatch(/source.kind/);
|
||||
});
|
||||
|
||||
let env: Awaited<ReturnType<typeof setupApp>>;
|
||||
afterEach(() => env?.stop());
|
||||
|
||||
test("publish by a non-owner is 403 when the merged manifest makes an imported course visible; the owner may", async () => {
|
||||
env = await setupApp();
|
||||
const { call, feature } = env;
|
||||
await call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" });
|
||||
// the published course is an import (as an importer-adopted course would be)
|
||||
const pub = feature.store.paths("c1").published;
|
||||
const m0 = readFileSync(join(pub, "stcourse.yaml"), "utf8").replace("kind: authored", "kind: packt-video");
|
||||
writeFileSync(join(pub, "stcourse.yaml"), m0);
|
||||
await extGit(pub, "commit", "-qam", "imported");
|
||||
await feature.watcher.check();
|
||||
const ada = { id: "u-ada", name: "Ada", email: "ada@example.org" };
|
||||
|
||||
// save-time check stops the plain attempt
|
||||
const st = (await call("ada", "GET", "/api/studio/courses/c1")).json;
|
||||
expect((await call("ada", "POST", "/api/studio/courses/c1/update-draft")).status).toBe(200);
|
||||
const st2 = (await call("ada", "GET", "/api/studio/courses/c1")).json;
|
||||
expect((await call("ada", "PUT", "/api/studio/courses/c1/files", { message: "x", manifest: { ...st2.manifestRaw, visibility: "public" }, manifestBaseHash: st2.manifestHash, files: [] })).status).toBe(403);
|
||||
void st;
|
||||
|
||||
// a draft that got there another way (AI draft writer, merge) is stopped at publish
|
||||
for (const edit of [(t: string) => t.replace("visibility: restricted", "visibility: public"), (t: string) => t.replace("visibility: restricted", "visibility: public").replace("kind: packt-video", "kind: authored")]) {
|
||||
const cur = (await feature.store.readFile("c1", ada.id, "stcourse.yaml"))!;
|
||||
await feature.store.saveFiles("c1", ada, [{ path: "stcourse.yaml", content: edit(cur.content), baseHash: cur.hash }], "sneak");
|
||||
const r = await call("ada", "POST", "/api/studio/courses/c1/publish");
|
||||
expect(r.status).toBe(403);
|
||||
expect(r.json.reason).toBe("forbidden");
|
||||
expect(readFileSync(join(pub, "stcourse.yaml"), "utf8")).toBe(m0);
|
||||
expect(await extGit(pub, "status", "--porcelain")).toBe("");
|
||||
await feature.store.saveFiles("c1", ada, [{ path: "stcourse.yaml", content: cur.content }], "undo");
|
||||
}
|
||||
|
||||
// the owner may
|
||||
const own = (await call("owner", "GET", "/api/studio/courses/c1")).json;
|
||||
expect((await call("owner", "PUT", "/api/studio/courses/c1/files", { message: "public", manifest: { ...own.manifestRaw, visibility: "public" }, manifestBaseHash: own.manifestHash, files: [] })).status).toBe(200);
|
||||
expect((await call("owner", "POST", "/api/studio/courses/c1/publish")).status).toBe(200);
|
||||
expect(readFileSync(join(pub, "stcourse.yaml"), "utf8")).toContain("visibility: public");
|
||||
});
|
||||
|
||||
test("resolve of the manifest by a non-owner is checked too", async () => {
|
||||
const { store } = await newStore();
|
||||
await store.createCourse({ id: "c1", title: "C" }, ada);
|
||||
const pub = store.paths("c1").published;
|
||||
const cur = (await store.readFile("c1", ada.id, "stcourse.yaml"))!;
|
||||
await store.saveFiles("c1", ada, [{ path: "stcourse.yaml", content: cur.content.replace("title: C", "title: Mine"), baseHash: cur.hash }], "mine");
|
||||
writeFileSync(join(pub, "stcourse.yaml"), cur.content.replace("title: C", "title: Theirs").replace("kind: authored", "kind: packt-video"));
|
||||
await extGit(pub, "commit", "-qam", "theirs");
|
||||
expect((await store.updateDraft("c1", ada)).ok).toBe(false);
|
||||
const bad = cur.content.replace("kind: authored", "kind: packt-video").replace("visibility: restricted", "visibility: public");
|
||||
await expect(store.resolve("c1", ada, [{ path: "stcourse.yaml", content: bad }])).rejects.toThrow(/owner/);
|
||||
expect((await store.resolve("c1", { ...ada, owner: true }, [{ path: "stcourse.yaml", content: bad }])).remaining).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("I2: uploads", () => {
|
||||
let env: Awaited<ReturnType<typeof setupApp>>;
|
||||
afterEach(() => { delete process.env.STC_UPLOAD_MAX_MB; return env?.stop(); });
|
||||
const upload = (name: string, bytes: Uint8Array<ArrayBuffer>, extra: Record<string, string> = {}) => {
|
||||
const fd = new FormData();
|
||||
fd.set("file", new File([bytes], name));
|
||||
fd.set("dir", "media");
|
||||
for (const [k, v] of Object.entries(extra)) fd.set(k, v);
|
||||
return env.call("ada", "POST", "/api/studio/courses/c1/upload", fd);
|
||||
};
|
||||
|
||||
test("type allowlist, manifest refused, no silent overwrite", async () => {
|
||||
env = await setupApp();
|
||||
await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" });
|
||||
expect((await upload("tool.exe", new Uint8Array([1]))).status).toBe(415);
|
||||
expect((await upload("page.HTM", new Uint8Array([1]))).status).toBe(415);
|
||||
expect((await upload("x", new Uint8Array([1]), { path: "stcourse.yaml" })).status).toBe(400);
|
||||
expect((await upload("slides.pdf", new Uint8Array([1]))).status).toBe(200);
|
||||
const again = await upload("slides.pdf", new Uint8Array([2]));
|
||||
expect(again.status).toBe(409);
|
||||
expect(readFileSync(join(env.dataDir, "worktrees/c1/drafts/u-ada/media/slides.pdf"))[0]).toBe(1);
|
||||
expect((await upload("slides.pdf", new Uint8Array([2]), { overwrite: "true" })).status).toBe(200);
|
||||
expect(readFileSync(join(env.dataDir, "worktrees/c1/drafts/u-ada/media/slides.pdf"))[0]).toBe(2);
|
||||
});
|
||||
|
||||
test("size cap from STC_UPLOAD_MAX_MB, by Content-Length and while streaming", async () => {
|
||||
env = await setupApp();
|
||||
await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" });
|
||||
process.env.STC_UPLOAD_MAX_MB = "1";
|
||||
expect((await upload("big.zip", new Uint8Array(1024 * 1024 + 10))).status).toBe(413);
|
||||
// no Content-Length: a streamed body is cut off once it passes the cap
|
||||
const fd = new FormData();
|
||||
fd.set("file", new File([new Uint8Array(2 * 1024 * 1024)], "big.zip"));
|
||||
const ct = new Response(fd).headers.get("content-type")!;
|
||||
const bytes = new Uint8Array(await new Response(fd).arrayBuffer());
|
||||
const stream = new ReadableStream({ start(c) { for (let i = 0; i < bytes.length; i += 65536) c.enqueue(bytes.slice(i, i + 65536)); c.close(); } });
|
||||
const r = await env.app.fetch(new Request("http://x/api/studio/courses/c1/upload", { method: "POST", headers: { "x-user": "ada", "content-type": ct }, body: stream, duplex: "half" } as RequestInit));
|
||||
expect(r.status).toBe(413);
|
||||
expect(existsSync(join(env.dataDir, "worktrees/c1/drafts/u-ada/big.zip"))).toBe(false);
|
||||
expect((await upload("small.zip", new Uint8Array(1000))).status).toBe(200);
|
||||
});
|
||||
|
||||
test("raw draft files are served with a sandboxing CSP", async () => {
|
||||
env = await setupApp();
|
||||
await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" });
|
||||
await upload("x.svg", new TextEncoder().encode("<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>"));
|
||||
const r = await env.app.fetch(new Request("http://x/api/studio/courses/c1/raw/media/x.svg", { headers: { "x-user": "ada" } }));
|
||||
expect(r.headers.get("content-security-policy")).toContain("sandbox");
|
||||
});
|
||||
});
|
||||
|
||||
describe("I4: layout and imported courses", () => {
|
||||
let env: Awaited<ReturnType<typeof setupApp>>;
|
||||
afterEach(() => env?.stop());
|
||||
|
||||
test("imported courses in <dataDir>/courses are read-only with their own message; ids cannot collide", async () => {
|
||||
env = await setupApp([], undefined, (dataDir) => {
|
||||
mkdirSync(join(dataDir, "courses/imp/lessons"), { recursive: true });
|
||||
writeFileSync(join(dataDir, "courses/imp/lessons/a.md"), "# A\n");
|
||||
writeFileSync(join(dataDir, "courses/imp/stcourse.yaml"), "schema: 1\nid: imp\ntitle: Imported\nsource: { kind: packt-video }\nmodules:\n - { id: m, title: M, lessons: [{ id: a, title: A, type: text, file: lessons/a.md }] }\n");
|
||||
});
|
||||
expect(env.ctx.courses.get("imp")?.status).toBe("ok");
|
||||
const list = (await env.call("ada", "GET", "/api/studio/courses")).json;
|
||||
expect(list.courses).toContainEqual(expect.objectContaining({ id: "imp", managed: false, message: "Imported course: read-only in the studio for now" }));
|
||||
expect((await env.call("ada", "GET", "/api/studio/courses/imp")).json.message).toBe("Imported course: read-only in the studio for now");
|
||||
const w = await env.call("ada", "PUT", "/api/studio/courses/imp/files", { message: "x", files: [{ path: "a.md", content: "x" }] });
|
||||
expect(w.status).toBe(403);
|
||||
expect(w.json.error).toBe("Imported course: read-only in the studio for now");
|
||||
expect((await env.call("ada", "POST", "/api/studio/courses", { id: "imp", title: "Dup" })).status).toBe(409);
|
||||
// studio repos are not in the importer's folder
|
||||
expect((await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" })).status).toBe(201);
|
||||
expect(existsSync(join(env.dataDir, "studio/repos/c1.git/HEAD"))).toBe(true);
|
||||
expect(existsSync(join(env.dataDir, "courses/c1"))).toBe(false);
|
||||
});
|
||||
|
||||
test("store refuses an id already used by an importer folder", async () => {
|
||||
const { store, dataDir } = await newStore();
|
||||
mkdirSync(join(dataDir, "courses/imp"), { recursive: true });
|
||||
await expect(store.createCourse({ id: "imp", title: "x" }, ada)).rejects.toThrow(/exists/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("I5: listing has no side effects", () => {
|
||||
let env: Awaited<ReturnType<typeof setupApp>>;
|
||||
afterEach(() => env?.stop());
|
||||
|
||||
test("GET /api/studio/courses creates no draft worktree or branch; opening the editor does", async () => {
|
||||
env = await setupApp();
|
||||
await env.call("owner", "POST", "/api/studio/courses", { id: "c1", title: "C" });
|
||||
const bare = env.feature.store.paths("c1").bare;
|
||||
const draft = env.feature.store.paths("c1").draft("u-ada");
|
||||
const list = await env.call("ada", "GET", "/api/studio/courses");
|
||||
expect(list.json.courses[0]).toMatchObject({ id: "c1", ahead: 0, behind: 0, merging: false });
|
||||
expect(existsSync(draft)).toBe(false);
|
||||
expect(await extGit(bare, "for-each-ref", "--format=%(refname)", "refs/heads/draft")).toBe("");
|
||||
await env.call("ada", "GET", "/api/studio/courses/c1");
|
||||
expect(existsSync(draft)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { loadCourse } from "@stc/course-format";
|
||||
import { contentHash } from "../src/server/store";
|
||||
import { ada, bob, extGit, newStore } from "./helpers";
|
||||
|
||||
describe("createCourse", () => {
|
||||
test("makes a bare repo, a published worktree on main and a valid manifest", async () => {
|
||||
const { dataDir, store } = await newStore();
|
||||
await store.createCourse({ id: "intro-git", title: "Intro: Git & you" }, ada);
|
||||
expect(existsSync(join(dataDir, "studio/repos/intro-git.git/HEAD"))).toBe(true);
|
||||
expect(existsSync(join(dataDir, "studio/repos/intro-git.git/stcourse.yaml"))).toBe(false);
|
||||
expect(existsSync(join(dataDir, "courses"))).toBe(false); // the importer's folder is left alone
|
||||
const pub = store.paths("intro-git").published;
|
||||
expect(pub).toBe(join(dataDir, "worktrees/intro-git/published"));
|
||||
expect(await extGit(pub, "rev-parse", "--abbrev-ref", "HEAD")).toBe("main");
|
||||
const loaded = await loadCourse(join(pub, "stcourse.yaml"));
|
||||
expect(loaded.status).toBe("ok");
|
||||
expect(loaded.manifest?.title).toBe("Intro: Git & you");
|
||||
expect(await extGit(pub, "log", "-1", "--format=%an <%ae>")).toBe("Ada Lovelace <ada@example.org>");
|
||||
expect(store.isManaged("intro-git")).toBe(true);
|
||||
expect(await store.listManaged()).toEqual(["intro-git"]);
|
||||
});
|
||||
|
||||
test("rejects bad and duplicate ids", async () => {
|
||||
const { store } = await newStore();
|
||||
await expect(store.createCourse({ id: "Bad Id", title: "x" }, ada)).rejects.toThrow(/id/);
|
||||
await store.createCourse({ id: "c1", title: "x" }, ada);
|
||||
await expect(store.createCourse({ id: "c1", title: "y" }, ada)).rejects.toThrow(/exists/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("drafts and saving", () => {
|
||||
test("a draft worktree per author on draft/<author>; save commits as the author; published untouched", async () => {
|
||||
const { store } = await newStore();
|
||||
await store.createCourse({ id: "c1", title: "C" }, ada);
|
||||
const dir = await store.ensureDraft("c1", ada.id);
|
||||
expect(dir).toBe(store.paths("c1").draft(ada.id));
|
||||
expect(await extGit(dir, "rev-parse", "--abbrev-ref", "HEAD")).toBe("draft/u-ada");
|
||||
const cur = await store.readFile("c1", ada.id, "lessons/welcome.md");
|
||||
expect(cur).not.toBeNull();
|
||||
const r = await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "# Hello draft\n", baseHash: cur!.hash }], "Edit welcome");
|
||||
expect(r.ok).toBe(true);
|
||||
expect(await extGit(dir, "log", "-1", "--format=%an <%ae>|%s")).toBe("Ada Lovelace <ada@example.org>|Edit welcome");
|
||||
expect(readFileSync(join(store.paths("c1").published, "lessons/welcome.md"), "utf8")).not.toContain("Hello draft");
|
||||
const st = await store.status("c1", ada.id);
|
||||
expect(st.ahead).toBe(1);
|
||||
expect(st.behind).toBe(0);
|
||||
expect(st.merging).toBe(false);
|
||||
});
|
||||
|
||||
test("saving unchanged content makes no commit", async () => {
|
||||
const { store } = await newStore();
|
||||
await store.createCourse({ id: "c1", title: "C" }, ada);
|
||||
const cur = (await store.readFile("c1", ada.id, "lessons/welcome.md"))!;
|
||||
const r = await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: cur.content, baseHash: cur.hash }], "noop");
|
||||
expect(r).toEqual({ ok: true, commit: null });
|
||||
});
|
||||
|
||||
test("an external edit between load and save is a conflict, not an overwrite", async () => {
|
||||
const { store } = await newStore();
|
||||
await store.createCourse({ id: "c1", title: "C" }, ada);
|
||||
const cur = (await store.readFile("c1", ada.id, "lessons/welcome.md"))!;
|
||||
const file = join(store.paths("c1").draft(ada.id), "lessons/welcome.md");
|
||||
writeFileSync(file, "edited in VS Code\n");
|
||||
const r = await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "studio text\n", baseHash: cur.hash }], "x");
|
||||
expect(r.ok).toBe(false);
|
||||
if (r.ok) return;
|
||||
expect(r.conflicts).toEqual([{ path: "lessons/welcome.md", current: "edited in VS Code\n", currentHash: contentHash("edited in VS Code\n") }]);
|
||||
expect(readFileSync(file, "utf8")).toBe("edited in VS Code\n");
|
||||
});
|
||||
|
||||
test("baseHash null means the file must not exist yet", async () => {
|
||||
const { store } = await newStore();
|
||||
await store.createCourse({ id: "c1", title: "C" }, ada);
|
||||
const r = await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "x", baseHash: null }], "x");
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("paths with spaces and non-ASCII characters", async () => {
|
||||
const { store } = await newStore();
|
||||
await store.createCourse({ id: "c1", title: "C" }, ada);
|
||||
const p = "S08 - Containerizing/Æble notes ü.md";
|
||||
const r = await store.saveFiles("c1", ada, [{ path: p, content: "æøå\n", baseHash: null }], "Add notes");
|
||||
expect(r.ok).toBe(true);
|
||||
expect((await store.readFile("c1", ada.id, p))?.content).toBe("æøå\n");
|
||||
expect(await store.listFiles("c1", ada.id)).toContain(p);
|
||||
});
|
||||
|
||||
test("binary content and deletion", async () => {
|
||||
const { store } = await newStore();
|
||||
await store.createCourse({ id: "c1", title: "C" }, ada);
|
||||
const bytes = new Uint8Array([0, 1, 2, 255]);
|
||||
expect((await store.saveFiles("c1", ada, [{ path: "media/a.bin", content: bytes }], "bin")).ok).toBe(true);
|
||||
expect(new Uint8Array(readFileSync(join(store.paths("c1").draft(ada.id), "media/a.bin")))).toEqual(bytes);
|
||||
expect((await store.saveFiles("c1", ada, [{ path: "media/a.bin", delete: true }], "rm")).ok).toBe(true);
|
||||
expect(await store.listFiles("c1", ada.id)).not.toContain("media/a.bin");
|
||||
});
|
||||
|
||||
test("path traversal and .git are rejected", async () => {
|
||||
const { store } = await newStore();
|
||||
await store.createCourse({ id: "c1", title: "C" }, ada);
|
||||
for (const bad of ["../x.md", "/etc/passwd", ".git/config", "a/../../x", "", "a\0b"]) {
|
||||
await expect(store.saveFiles("c1", ada, [{ path: bad, content: "x" }], "x")).rejects.toThrow(/path/i);
|
||||
}
|
||||
expect(await store.readFile("c1", ada.id, "../../../etc/passwd")).toBeNull();
|
||||
});
|
||||
|
||||
test("authors get separate drafts; odd author ids are made safe", async () => {
|
||||
const { store } = await newStore();
|
||||
await store.createCourse({ id: "c1", title: "C" }, ada);
|
||||
const a = await store.ensureDraft("c1", ada.id);
|
||||
const b = await store.ensureDraft("c1", "bob/../x y");
|
||||
expect(a).not.toBe(b);
|
||||
expect(await extGit(b, "rev-parse", "--abbrev-ref", "HEAD")).toMatch(/^draft\/[A-Za-z0-9_-]+$/);
|
||||
void bob;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createCourseRepository } from "@stc/course-format";
|
||||
import { createPublishedWatcher } from "../src/server/watch";
|
||||
import { studioCourseDirs } from "../src/server/paths";
|
||||
import { ada, extGit, newStore } from "./helpers";
|
||||
|
||||
test("an external commit in the published worktree reloads the CourseRepository", async () => {
|
||||
const { dataDir, store } = await newStore();
|
||||
const courses = await createCourseRepository(studioCourseDirs({ dataDir, courseDirs: [] }));
|
||||
let reloads = 0;
|
||||
const realReload = courses.reload.bind(courses);
|
||||
courses.reload = async (id?: string) => { reloads++; await realReload(id); };
|
||||
const w = createPublishedWatcher(store, courses, { intervalMs: 50 });
|
||||
|
||||
await store.createCourse({ id: "c1", title: "C" }, ada);
|
||||
expect(await w.check()).toEqual(["c1"]); // new course, not yet loaded
|
||||
expect(courses.get("c1")?.status).toBe("ok");
|
||||
expect(await w.check()).toEqual([]);
|
||||
|
||||
const pub = store.paths("c1").published;
|
||||
writeFileSync(join(pub, "lessons/welcome.md"), "# External\n");
|
||||
await extGit(pub, "commit", "-qam", "external");
|
||||
expect(await w.check()).toEqual(["c1"]);
|
||||
expect(await courses.readLessonMarkdown("c1", "welcome")).toBe("# External\n");
|
||||
expect(reloads).toBe(2);
|
||||
|
||||
// the timer path
|
||||
writeFileSync(join(pub, "lessons/welcome.md"), "# External 2\n");
|
||||
await extGit(pub, "commit", "-qam", "external 2");
|
||||
w.start();
|
||||
for (let i = 0; i < 40 && (await courses.readLessonMarkdown("c1", "welcome")) !== "# External 2\n"; i++) await Bun.sleep(25);
|
||||
w.stop();
|
||||
expect(await courses.readLessonMarkdown("c1", "welcome")).toBe("# External 2\n");
|
||||
});
|
||||
|
||||
test("drafts are not discovered as courses", async () => {
|
||||
const { dataDir, store } = await newStore();
|
||||
await store.createCourse({ id: "c1", title: "C" }, ada);
|
||||
await store.ensureDraft("c1", ada.id);
|
||||
const courses = await createCourseRepository(studioCourseDirs({ dataDir, courseDirs: [] }));
|
||||
expect(courses.list().map((c) => [c.id, c.root])).toEqual([["c1", store.paths("c1").published]]);
|
||||
});
|
||||
Reference in New Issue
Block a user