feat(studio): studio web UI (course list, manifest, lessons with live preview, decks, attachments, exercises, conflicts, sync) and dev entry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 03:26:18 +01:00
1 parent 497ce91216
commit 0992df7257
18 files changed
+1403 -5

No files matched your search

+14 -3
View File
@@ -15,9 +15,20 @@ export const users: Record<string, AuthUser> = {
};
/** A server context like main.ts builds, with header-selected users (`x-user: ada`). */
export async function setupApp(extraCourseDirs: string[] = []) {
export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite://:memory:") {
const dataDir = tempDir();
const db = await connect("sqlite://:memory:");
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));
@@ -39,5 +50,5 @@ export async function setupApp(extraCourseDirs: string[] = []) {
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(); } };
return { ctx, db, dataDir, feature, app, call, stop: async () => { feature.stop(); await db.close(); await dropSchema(); } };
}
+4 -2
View File
@@ -11,8 +11,10 @@ test("anonymous gets 401, learners 403", async () => {
expect((await env.call("lea", "POST", "/api/studio/courses", { id: "x", title: "X" })).status).toBe(403);
});
test("creating a course records the creator in course_authors; a per-course author without the role can edit it", async () => {
env = await setupApp();
const DBS = [["sqlite", "sqlite://:memory:"], ...(process.env.STC_TEST_PG_URL ? [["postgres", process.env.STC_TEST_PG_URL]] : [])] as const;
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"]]);
@@ -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 &lt; y</code>.\nSecond line.</p>',
);
});
test("raw HTML is escaped, except the details/summary hint blocks", () => {
expect(renderMarkdown("<script>alert(1)</script>")).toBe("<p>&lt;script&gt;alert(1)&lt;/script&gt;</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 = \'&lt;b&gt;\';\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&amp;c=2">ok</a></p>');
expect(renderMarkdown("[bad](javascript:alert(1))")).toBe("<p>bad</p>");
expect(renderMarkdown("![d](img/a b.png)", { 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>');
});