feat: resolve Phase 1 contract requests and compose every feature

Contracts and core:
- Feature gains auth and stop; FeatureFactory; core composeServer builds the
  context, runs the factories, then the job queue from the union of their jobs
  (R8/R14). startServer uses it; main.ts composes learner, import, studio, AI.
- AppConfig/loadConfig own the layout and settings: courseDirs include
  <dataDir>/courses and <dataDir>/worktrees, studioReposDir, cacheDir,
  importRoots, uploadMaxMb, aiAllowedBaseUrls, transcribeUrl, python.
  resolveConfig derives the same for tests; cachePaths shares the importer's
  cache layout.
- canView: the global author role views every course (R14); the learner,
  studio and AI packages all call it.
- migrate(db, packages): core first, then packages in name order.
- Media: nosniff on every response, a sandbox CSP on svg/html/js; a Range on
  an empty file is 416.
- courseUrl/lessonUrl and the lesson route patterns live in @stc/contracts.

Packages:
- import, studio and AI read AppConfig instead of process.env; the importer
  refuses ids used by studio/repos.
- learner migrations run through core migrate; library covers fall back to
  the importer's cached candidate behind a canView-checked route.
- studio preview renders through the learner LessonView (anchors honoured,
  draft URLs); AI drafts go to the studio draft branch for studio courses (as
  the requesting user) and to <dataDir>/drafts otherwise, and the generator
  page says which.

Web: tsconfig paths for @stc/<feature>/web browser entries; features.ts
imports them by package name; nav.roles hides links by role.

Tests: compose, config, canView, media headers, migrations order, covers,
preview, nav, SPA bundle without server code, and an end-to-end flow on
SQLite and Postgres.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 15:02:46 +01:00
co-authored by Claude Opus 5.5
parent 427eda6e93
commit 777ae2cc37
72 changed files with 1066 additions and 244 deletions
+1 -2
View File
@@ -182,8 +182,7 @@ export function fmtSize(n: number | null | undefined): string {
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
export const lessonHref = (courseId: string, lessonId: string) => `/c/${encodeURIComponent(courseId)}/${encodeURIComponent(lessonId)}`;
export const courseHref = (courseId: string) => `/c/${encodeURIComponent(courseId)}`;
export { lessonUrl as lessonHref, courseUrl as courseHref } from "@stc/contracts";
export async function copyText(text: string): Promise<boolean> {
try { await navigator.clipboard.writeText(text); return true; } catch {
+4 -3
View File
@@ -1,6 +1,7 @@
import type { WebFeature } from "@stc/contracts";
import { COURSE_ROUTE, LESSON_ROUTE, type WebFeature } from "@stc/contracts";
export { LessonView, type LessonViewProps } from "./lesson/LessonView";
export type { LessonPayload } from "../shared/types";
export { ensureStyles as ensureLearnerStyles } from "./api";
/** Learner routes: library, course pages, lessons, notes and the account pages. */
@@ -8,9 +9,9 @@ export const learnerWeb: WebFeature = {
name: "learner",
routes: [
{ path: "/", element: () => import("./library/LibraryPage") },
{ path: "/c/:courseId", element: () => import("./course/pages").then((m) => ({ default: m.CoursePage })) },
{ path: COURSE_ROUTE, element: () => import("./course/pages").then((m) => ({ default: m.CoursePage })) },
{ path: "/c/:courseId/notes", element: () => import("./course/pages").then((m) => ({ default: m.NotesPage })) },
{ path: "/c/:courseId/:lessonId", element: () => import("./course/pages").then((m) => ({ default: m.LessonPage })) },
{ path: LESSON_ROUTE, element: () => import("./course/pages").then((m) => ({ default: m.LessonPage })) },
{ path: "/account", element: () => import("./account/AccountPage") },
{ path: "/account/admin", element: () => import("./account/AdminPage") },
],
@@ -24,6 +24,8 @@ export interface LessonViewProps {
signedIn?: boolean;
/** Studio preview: no notes, no saved attempts, no progress reports. */
preview?: boolean;
/** URL for a course-relative file in the lesson text (the studio preview points these at draft files). */
mediaUrl?: (courseRel: string) => string;
onProgress?: (state: ProgressState, positionSeconds?: number) => void;
/** An exercise attempt was saved (the server already updated progress). */
onSaved?: () => void;
@@ -85,12 +87,12 @@ function TrackSwitch({ lesson, track, onChange }: { lesson: LessonPayload; track
);
}
function useLessonHtml(lesson: LessonPayload, track: string | null): string | null {
function useLessonHtml(lesson: LessonPayload, track: string | null, media?: (courseRel: string) => string): string | null {
return useMemo(() => {
const md = (track && lesson.trackMarkdown[track]) || lesson.markdown;
if (!md) return null;
return renderLessonHtml(md, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles, stripFirstHeading: true });
}, [lesson, track]);
return renderLessonHtml(md, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles, stripFirstHeading: true, mediaUrl: media });
}, [lesson, track, media]);
}
function Decks({ lesson, notes, initialSlide, keyboard }: { lesson: LessonPayload; notes: NotesApi | null; initialSlide?: { deckId: string; page: number } | null; keyboard: boolean }) {
@@ -109,7 +111,7 @@ function Decks({ lesson, notes, initialSlide, keyboard }: { lesson: LessonPayloa
function TextLesson(p: LessonViewProps & { chap: string; track: string | null; notes: NotesApi }) {
const { lesson, track } = p;
const html = useLessonHtml(lesson, track);
const html = useLessonHtml(lesson, track, p.mediaUrl);
const cmd = localCommandFor(lesson.localCommand, track);
const minutes = lesson.lesson.minutes;
const hasMeta = !!minutes || lesson.tracks.length > 1;
@@ -228,9 +230,9 @@ function useVideoProgress(v: React.RefObject<HTMLVideoElement | null>, p: Lesson
function ExerciseLesson(p: LessonViewProps & { chap: string; track: string | null }) {
const { lesson, track } = p;
const html = useLessonHtml(lesson, track);
const html = useLessonHtml(lesson, track, p.mediaUrl);
const hintsHtml = useMemo(() => (lesson.hints.length
? renderHintsHtml(lesson.hints, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles })
? renderHintsHtml(lesson.hints, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles, mediaUrl: p.mediaUrl })
: null), [lesson, track]);
const cmd = localCommandFor(lesson.localCommand, track);
return (
+3 -1
View File
@@ -24,6 +24,8 @@ export interface RenderOptions {
lessonFiles?: Record<string, string>;
/** Drop a leading heading (the page shows the lesson title itself). */
stripFirstHeading?: boolean;
/** URL for a course-relative file; default `/api/media/<course>/<path>` (the studio preview serves draft files). */
mediaUrl?: (courseRel: string) => string;
}
const escapeHtml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -226,7 +228,7 @@ export function resolveHref(href: string, opts: RenderOptions, kind: "link" | "i
if (rel === null) return null;
const lessonId = kind === "link" ? opts.lessonFiles?.[rel] : undefined;
if (lessonId) return { url: `/c/${encodeURIComponent(opts.courseId)}/${encodeURIComponent(lessonId)}${hash ? `#${hash}` : ""}`, external: false };
return { url: mediaUrl(opts.courseId, rel) + (hash ? `#${hash}` : ""), external: false };
return { url: (opts.mediaUrl ? opts.mediaUrl(rel) : mediaUrl(opts.courseId, rel)) + (hash ? `#${hash}` : ""), external: false };
}
export function renderLessonHtml(md: string, opts: RenderOptions): string {