MVP
This commit is contained in:
@@ -2,10 +2,36 @@ export const lessonContentTypes = ["VIDEO", "LECTURE", "ACTIVITY", "QUIZ", "FINA
|
||||
|
||||
export type LessonContentType = (typeof lessonContentTypes)[number];
|
||||
|
||||
export const lessonQuestionKinds = ["TRUE_FALSE", "MULTIPLE_CHOICE"] as const;
|
||||
|
||||
export type LessonQuestionKind = (typeof lessonQuestionKinds)[number];
|
||||
|
||||
export type LessonQuestionOption = {
|
||||
id: string;
|
||||
text: string;
|
||||
isCorrect: boolean;
|
||||
};
|
||||
|
||||
export type LessonQuestion = {
|
||||
id: string;
|
||||
kind: LessonQuestionKind;
|
||||
prompt: string;
|
||||
explanation: string;
|
||||
options: LessonQuestionOption[];
|
||||
};
|
||||
|
||||
export type LessonActivityMeta = {
|
||||
instructions: string;
|
||||
passingScorePercent: number;
|
||||
questions: LessonQuestion[];
|
||||
};
|
||||
|
||||
type LessonDescriptionMeta = {
|
||||
text: string;
|
||||
contentType: LessonContentType;
|
||||
materialUrl: string | null;
|
||||
lectureContent: string;
|
||||
activity: LessonActivityMeta | null;
|
||||
};
|
||||
|
||||
const lessonTypeAliases: Record<string, LessonContentType> = {
|
||||
@@ -33,6 +59,15 @@ function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeType(value: string): LessonContentType {
|
||||
const normalized = value.trim().toUpperCase();
|
||||
return lessonTypeAliases[normalized] ?? "VIDEO";
|
||||
@@ -53,12 +88,111 @@ function getDescriptionText(input: unknown): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeQuestionKind(value: unknown): LessonQuestionKind {
|
||||
const raw = asString(value).toUpperCase();
|
||||
return raw === "TRUE_FALSE" ? "TRUE_FALSE" : "MULTIPLE_CHOICE";
|
||||
}
|
||||
|
||||
function parseQuestionOptions(value: unknown, kind: LessonQuestionKind): LessonQuestionOption[] {
|
||||
if (!Array.isArray(value)) {
|
||||
if (kind === "TRUE_FALSE") {
|
||||
return [
|
||||
{ id: "true", text: "Verdadero", isCorrect: true },
|
||||
{ id: "false", text: "Falso", isCorrect: false },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const options = value
|
||||
.map((option, index) => {
|
||||
if (!isRecord(option)) return null;
|
||||
const id = asString(option.id) || `opt-${index + 1}`;
|
||||
const text = asString(option.text);
|
||||
const isCorrect = Boolean(option.isCorrect);
|
||||
if (!text) return null;
|
||||
return { id, text, isCorrect };
|
||||
})
|
||||
.filter((option): option is LessonQuestionOption => option !== null);
|
||||
|
||||
if (kind === "TRUE_FALSE") {
|
||||
const hasTrue = options.find((option) => option.id === "true" || option.text.toLowerCase() === "verdadero");
|
||||
const hasFalse = options.find((option) => option.id === "false" || option.text.toLowerCase() === "falso");
|
||||
if (!hasTrue || !hasFalse) {
|
||||
const correctIndex = options.findIndex((option) => option.isCorrect);
|
||||
return [
|
||||
{ id: "true", text: "Verdadero", isCorrect: correctIndex !== 1 },
|
||||
{ id: "false", text: "Falso", isCorrect: correctIndex === 1 },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function parseQuestion(value: unknown, index: number): LessonQuestion | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const kind = normalizeQuestionKind(value.kind ?? value.type);
|
||||
const id = asString(value.id) || `q-${index + 1}`;
|
||||
const prompt = asString(value.prompt ?? value.question);
|
||||
const explanation = asString(value.explanation);
|
||||
const options = parseQuestionOptions(value.options, kind);
|
||||
|
||||
if (!prompt) return null;
|
||||
if (options.length < 2) return null;
|
||||
|
||||
const hasCorrect = options.some((option) => option.isCorrect);
|
||||
const normalizedOptions = hasCorrect ? options : options.map((option, optIndex) => ({ ...option, isCorrect: optIndex === 0 }));
|
||||
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
prompt,
|
||||
explanation,
|
||||
options: normalizedOptions,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePassingScore(value: unknown, fallback: number): number {
|
||||
const parsed = asNumber(value);
|
||||
if (parsed == null) return fallback;
|
||||
return Math.max(0, Math.min(100, Math.round(parsed)));
|
||||
}
|
||||
|
||||
function parseActivityMeta(description: Record<string, unknown>): LessonActivityMeta | null {
|
||||
const activityRaw =
|
||||
(isRecord(description.activity) && description.activity) ||
|
||||
(isRecord(description.quiz) && description.quiz) ||
|
||||
(isRecord(description.exercise) && description.exercise) ||
|
||||
null;
|
||||
|
||||
if (!activityRaw) return null;
|
||||
|
||||
const instructions = asString(activityRaw.instructions ?? activityRaw.intro ?? description.es ?? description.text);
|
||||
const passingScorePercent = normalizePassingScore(activityRaw.passingScorePercent, 70);
|
||||
const questionsRaw = Array.isArray(activityRaw.questions) ? activityRaw.questions : [];
|
||||
const questions = questionsRaw
|
||||
.map((question, index) => parseQuestion(question, index))
|
||||
.filter((question): question is LessonQuestion => question !== null);
|
||||
|
||||
if (questions.length === 0) return null;
|
||||
|
||||
return {
|
||||
instructions,
|
||||
passingScorePercent,
|
||||
questions,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseLessonDescriptionMeta(description: unknown): LessonDescriptionMeta {
|
||||
if (!isRecord(description)) {
|
||||
const fallbackText = getDescriptionText(description);
|
||||
return {
|
||||
text: getDescriptionText(description),
|
||||
text: fallbackText,
|
||||
contentType: "VIDEO",
|
||||
materialUrl: null,
|
||||
lectureContent: fallbackText,
|
||||
activity: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,11 +203,16 @@ export function parseLessonDescriptionMeta(description: unknown): LessonDescript
|
||||
asString(description.pdfUrl) ||
|
||||
asString(description.attachmentUrl) ||
|
||||
"";
|
||||
const text = getDescriptionText(description);
|
||||
const lectureContent = asString(description.lectureContent) || asString(description.content) || text;
|
||||
const activity = parseActivityMeta(description);
|
||||
|
||||
return {
|
||||
text: getDescriptionText(description),
|
||||
text,
|
||||
contentType: normalizeType(contentTypeRaw || "VIDEO"),
|
||||
materialUrl: materialUrl || null,
|
||||
lectureContent,
|
||||
activity,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,8 +220,10 @@ export function buildLessonDescriptionMeta(input: {
|
||||
text: string;
|
||||
contentType: LessonContentType;
|
||||
materialUrl?: string | null;
|
||||
}): Record<string, string> {
|
||||
const payload: Record<string, string> = {
|
||||
lectureContent?: string | null;
|
||||
activity?: LessonActivityMeta | null;
|
||||
}): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
contentType: input.contentType,
|
||||
};
|
||||
|
||||
@@ -92,6 +233,44 @@ export function buildLessonDescriptionMeta(input: {
|
||||
const materialUrl = (input.materialUrl ?? "").trim();
|
||||
if (materialUrl) payload.materialUrl = materialUrl;
|
||||
|
||||
const lectureContent = (input.lectureContent ?? "").trim();
|
||||
if (lectureContent) payload.lectureContent = lectureContent;
|
||||
|
||||
const activity = input.activity;
|
||||
if (activity && activity.questions.length > 0) {
|
||||
payload.activity = {
|
||||
instructions: activity.instructions.trim(),
|
||||
passingScorePercent: Math.max(0, Math.min(100, Math.round(activity.passingScorePercent))),
|
||||
questions: activity.questions.map((question, qIndex) => {
|
||||
const questionId = question.id.trim() || `q-${qIndex + 1}`;
|
||||
const prompt = question.prompt.trim();
|
||||
const explanation = question.explanation.trim();
|
||||
const kind: LessonQuestionKind = question.kind === "TRUE_FALSE" ? "TRUE_FALSE" : "MULTIPLE_CHOICE";
|
||||
const options = question.options
|
||||
.map((option, optionIndex) => {
|
||||
const optionId = option.id.trim() || `opt-${optionIndex + 1}`;
|
||||
const optionText = option.text.trim();
|
||||
if (!optionText) return null;
|
||||
return {
|
||||
id: optionId,
|
||||
text: optionText,
|
||||
isCorrect: Boolean(option.isCorrect),
|
||||
};
|
||||
})
|
||||
.filter((option): option is LessonQuestionOption => option !== null);
|
||||
const hasCorrect = options.some((option) => option.isCorrect);
|
||||
const normalizedOptions = hasCorrect ? options : options.map((option, optionIndex) => ({ ...option, isCorrect: optionIndex === 0 }));
|
||||
return {
|
||||
id: questionId,
|
||||
kind,
|
||||
prompt,
|
||||
explanation,
|
||||
options: normalizedOptions,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
||||
126
lib/courses/lessonMarkdown.ts
Normal file
126
lib/courses/lessonMarkdown.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return value.replaceAll("&", "&").replaceAll('"', """);
|
||||
}
|
||||
|
||||
function normalizeHttpUrl(rawUrl: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderInline(markdown: string): string {
|
||||
let rendered = escapeHtml(markdown);
|
||||
|
||||
rendered = rendered.replace(/`([^`]+)`/g, (_match, value: string) => `<code>${value}</code>`);
|
||||
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, (_match, value: string) => `<strong>${value}</strong>`);
|
||||
rendered = rendered.replace(/\*([^*]+)\*/g, (_match, value: string) => `<em>${value}</em>`);
|
||||
rendered = rendered.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label: string, url: string) => {
|
||||
const safeUrl = normalizeHttpUrl(url);
|
||||
if (!safeUrl) return label;
|
||||
return `<a href="${escapeAttribute(safeUrl)}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
||||
});
|
||||
|
||||
return rendered;
|
||||
}
|
||||
|
||||
export function markdownToSafeHtml(markdown: string): string {
|
||||
const source = markdown.replace(/\r\n/g, "\n").trim();
|
||||
if (!source) return "<p>Sin contenido.</p>";
|
||||
|
||||
const lines = source.split("\n");
|
||||
const output: string[] = [];
|
||||
let listMode: "ol" | "ul" | null = null;
|
||||
|
||||
const closeList = () => {
|
||||
if (!listMode) return;
|
||||
output.push(listMode === "ol" ? "</ol>" : "</ul>");
|
||||
listMode = null;
|
||||
};
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (!line) {
|
||||
closeList();
|
||||
continue;
|
||||
}
|
||||
|
||||
const h3 = line.match(/^###\s+(.+)$/);
|
||||
if (h3) {
|
||||
closeList();
|
||||
output.push(`<h3>${renderInline(h3[1])}</h3>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const h2 = line.match(/^##\s+(.+)$/);
|
||||
if (h2) {
|
||||
closeList();
|
||||
output.push(`<h2>${renderInline(h2[1])}</h2>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const h1 = line.match(/^#\s+(.+)$/);
|
||||
if (h1) {
|
||||
closeList();
|
||||
output.push(`<h1>${renderInline(h1[1])}</h1>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ordered = line.match(/^\d+\.\s+(.+)$/);
|
||||
if (ordered) {
|
||||
if (listMode !== "ol") {
|
||||
closeList();
|
||||
output.push("<ol>");
|
||||
listMode = "ol";
|
||||
}
|
||||
output.push(`<li>${renderInline(ordered[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const unordered = line.match(/^[-*]\s+(.+)$/);
|
||||
if (unordered) {
|
||||
if (listMode !== "ul") {
|
||||
closeList();
|
||||
output.push("<ul>");
|
||||
listMode = "ul";
|
||||
}
|
||||
output.push(`<li>${renderInline(unordered[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const quote = line.match(/^>\s+(.+)$/);
|
||||
if (quote) {
|
||||
closeList();
|
||||
output.push(`<blockquote>${renderInline(quote[1])}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
closeList();
|
||||
output.push(`<p>${renderInline(line)}</p>`);
|
||||
}
|
||||
|
||||
closeList();
|
||||
return output.join("\n");
|
||||
}
|
||||
|
||||
export function markdownToPlainText(markdown: string): string {
|
||||
return markdown
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1")
|
||||
.replace(/[`*_>#-]/g, "")
|
||||
.replace(/\n{2,}/g, "\n")
|
||||
.trim();
|
||||
}
|
||||
Reference in New Issue
Block a user