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:
co-authored by
Claude Opus 5.5
parent
427eda6e93
commit
777ae2cc37
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user