diff --git a/apps/api/src/routes/adt-preview.ts b/apps/api/src/routes/adt-preview.ts index ab38a218..71b7ffec 100644 --- a/apps/api/src/routes/adt-preview.ts +++ b/apps/api/src/routes/adt-preview.ts @@ -64,6 +64,28 @@ function getMimeType(filePath: string): string { return MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream" } +/** Highest mtime across base.js + everything under modules/ — used to detect + * when the pre-built bundle is out of date relative to its sources. */ +function maxSourceMtime(webAssetsDir: string): number { + let max = 0 + const baseJs = path.join(webAssetsDir, "base.js") + if (fs.existsSync(baseJs)) max = Math.max(max, fs.statSync(baseJs).mtimeMs) + const modulesDir = path.join(webAssetsDir, "modules") + if (!fs.existsSync(modulesDir)) return max + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) walk(full) + else if (entry.isFile()) { + const m = fs.statSync(full).mtimeMs + if (m > max) max = m + } + } + } + walk(modulesDir) + return max +} + // --------------------------------------------------------------------------- // Caches (built lazily per book) // --------------------------------------------------------------------------- @@ -443,21 +465,28 @@ export function createAdtPreviewRoutes( throw new HTTPException(403, { message: "Forbidden" }) } - // Auto-build base.bundle.min.js on-the-fly if it doesn't exist (dev mode). - // The file is gitignored and normally pre-built by `pnpm --filter @adt/api bundle`. - if (!fs.existsSync(resolved) && assetPath === "base.bundle.min.js") { + // Auto-build base.bundle.min.js on-the-fly if it's missing OR stale relative + // to its source modules (dev mode). The file is gitignored and normally + // pre-built by `pnpm --filter @adt/api bundle`. Skip in packaged mode + // (ADT_RESOURCES_ZIP) where esbuild isn't available inside the pkg'd binary. + if (assetPath === "base.bundle.min.js" && !process.env.ADT_RESOURCES_ZIP) { const entryPoint = path.join(webAssetsDir, "base.js") if (fs.existsSync(entryPoint)) { - const esbuild = await import("esbuild") - await esbuild.build({ - entryPoints: [entryPoint], - bundle: true, - minify: true, - sourcemap: true, - format: "esm", - target: "es2020", - outfile: resolved, - }) + const bundleMtime = fs.existsSync(resolved) ? fs.statSync(resolved).mtimeMs : 0 + const isStale = + bundleMtime === 0 || maxSourceMtime(webAssetsDir) > bundleMtime + if (isStale) { + const esbuild = await import("esbuild") + await esbuild.build({ + entryPoints: [entryPoint], + bundle: true, + minify: true, + sourcemap: true, + format: "esm", + target: "es2020", + outfile: resolved, + }) + } } } diff --git a/assets/adt/modules/activities/fill_in_the_blank.js b/assets/adt/modules/activities/fill_in_the_blank.js index 7309764c..9f6230cf 100644 --- a/assets/adt/modules/activities/fill_in_the_blank.js +++ b/assets/adt/modules/activities/fill_in_the_blank.js @@ -37,7 +37,7 @@ export const hydrateFitbSentences = (container) => { const hydratedHtml = html.replace( /\[\[blank:item-(\d+)(?::([^\]]+))?\]\]/g, - (_match, itemNum, hint) => { + (match, itemNum, hint, offset, source) => { ariaCounter++; const placeholderAttr = hint ? ` placeholder="${hint.replace(/"/g, '"')}"` @@ -49,13 +49,49 @@ export const hydrateFitbSentences = (container) => { const label = blankLabel.startsWith('fitb-') ? (totalBlanks > 1 ? `Blank ${ariaCounter} of ${totalBlanks}` : 'Blank') : blankLabel; - // Size the input to roughly fit the expected answer. - // Use the hint length when available, otherwise a sensible default. - const charWidth = hint ? Math.max(hint.length + 2, 6) : 8; + // Detect word-internal blanks — the marker sits INSIDE a word rather + // than between words (e.g. "en[[blank:item-1]]ro" for a missing-letter + // exercise). Used for tighter spacing, and as a fallback for sizing + // when no answer is available yet (e.g. preview before answers gen). + const WORD_CHAR = /\p{L}/u; + const prevChar = source[offset - 1] || ''; + const nextChar = source[offset + match.length] || ''; + const isWordInternal = + !hint && (WORD_CHAR.test(prevChar) || WORD_CHAR.test(nextChar)); + // Look up the correct answer for this blank (injected as a global by + // package-web). Pipe-separated alternatives use the longest variant so + // every acceptable answer fits. Size to answer length + 1 for safety. + const answer = typeof window !== 'undefined' + ? window.correctAnswers?.[`item-${itemNum}`] + : undefined; + const answerLength = typeof answer === 'string' && answer.length > 0 + ? answer.split('|').reduce((max, a) => Math.max(max, a.length), 0) + : 0; + // Size priority: + // 1. Explicit hint in the marker (`[[blank:item-N:hint]]`) — hint IS the answer shape + // 2. Known answer from window.correctAnswers — size to answer length + 1 + // 3. Word-internal heuristic — narrow single-letter slot + // 4. Fallback — default word-sized slot + let charWidth; + let minWidth; + if (hint) { + charWidth = Math.max(hint.length + 2, 6); + minWidth = '4ch'; + } else if (answerLength > 0) { + charWidth = Math.max(answerLength + 1, 2); + minWidth = answerLength <= 2 ? '1.5ch' : '4ch'; + } else if (isWordInternal) { + charWidth = 2; + minWidth = '1.5ch'; + } else { + charWidth = 8; + minWidth = '4ch'; + } + const spacingClasses = isWordInternal ? 'mx-px' : 'mx-1'; return ` { expect(result.valid).toBe(true) }) + it("accepts inline letter blanks: single-word source with single underscores replaced by markers", () => { + const html = ` +
+ en[[blank:item-1]]ro +
+ ` + const expectedTexts = new Map([["tx001", "en_ro"]]) + const result = validateSectionHtml( + html, + ["tx001"], + [], + undefined, + { expectedTexts } + ) + expect(result.valid).toBe(true) + expect(result.errors).toHaveLength(0) + expect(result.sectionHtml).toContain("[[blank:item-1]]") + }) + + it("accepts inline letter blanks with multiple underscores in a single word", () => { + const html = ` +
+ [[blank:item-1]]eptiembr[[blank:item-2]] +
+ ` + const expectedTexts = new Map([["tx001", "_eptiembr_"]]) + const result = validateSectionHtml( + html, + ["tx001"], + [], + undefined, + { expectedTexts } + ) + expect(result.valid).toBe(true) + expect(result.errors).toHaveLength(0) + }) + + it("strips orphan separator runs left behind after removing date-style blank runs", () => { + const html = ` +
+ +
+ ` + const expectedTexts = new Map([ + ["tx001", "Fecha de nacimiento: ___/___/___"], + ]) + const result = validateSectionHtml( + html, + ["tx001"], + [], + undefined, + { expectedTexts } + ) + expect(result.valid).toBe(true) + expect(result.errors).toHaveLength(0) + expect(result.sectionHtml).toContain("Fecha de nacimiento:") + expect(result.sectionHtml).not.toContain("//") + }) + + it("strips orphan separator runs with whitespace between the blanks", () => { + const html = ` +
+ +
+ ` + const expectedTexts = new Map([ + ["tx001", "Teléfono: ___ - ___ - ___"], + ]) + const result = validateSectionHtml( + html, + ["tx001"], + [], + undefined, + { expectedTexts } + ) + expect(result.valid).toBe(true) + expect(result.sectionHtml).not.toMatch(/-\s*-/) + }) + it("does not substitute expected text on image data-ids", () => { const html = `
@@ -1066,3 +1145,291 @@ describe("textSimilarity", () => { expect(textSimilarity("abc", "xyz")).toBe(0.0) }) }) + +describe("writable section types require editable inputs", () => { + it("fails activity_open_ended_answer with no textarea/input/blank marker", () => { + const html = ` +
+

Write your answer below:

+
+
+
+ ` + const result = validateSectionHtml(html, ["pg001_gp001"], []) + expect(result.valid).toBe(false) + expect(result.errors).toContainEqual( + expect.stringContaining( + 'Section type "activity_open_ended_answer" requires at least one editable element' + ) + ) + }) + + it("passes activity_open_ended_answer when a +
+ ` + const result = validateSectionHtml(html, ["pg001_gp001"], []) + expect(result.valid).toBe(true) + }) + + it("passes activity_fill_in_the_blank when a [[blank:item-N]] marker is present", () => { + const html = ` +
+

The capital of France is [[blank:item-1]].

+
+ ` + const result = validateSectionHtml( + html, + ["pg001_gp001"], + [], + undefined, + { + expectedTexts: new Map([ + ["pg001_gp001", "The capital of France is ___."], + ]), + } + ) + expect(result.valid).toBe(true) + }) + + it("fails activity_fill_in_a_table when no elements are present", () => { + const html = ` +
+ + + + + +
Name
+
+ ` + const result = validateSectionHtml(html, ["pg001_gp001"], []) + expect(result.valid).toBe(false) + expect(result.errors).toContainEqual( + expect.stringContaining( + 'Section type "activity_fill_in_a_table" requires at least one editable element' + ) + ) + }) + + it("does not require editable elements for non-writable section types", () => { + const html = ` +
+

Just some prose.

+
+ ` + const result = validateSectionHtml(html, ["pg001_gp001"], []) + expect(result.valid).toBe(true) + }) +}) + +describe("textbook blank placeholders may be omitted when editable element is provided", () => { + it("accepts label text with underscores stripped when a + + + ` + const result = validateSectionHtml( + html, + ["pg010_gp001_tx001"], + [], + undefined, + { + expectedTexts: new Map([["pg010_gp001_tx001", "Nombre: ___"]]), + } + ) + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + + it("accepts label with multiple stripped underscore runs (e.g. dates)", () => { + const html = ` +
+
+

Fecha de nacimiento:

+ +
+
+ ` + const result = validateSectionHtml( + html, + ["pg011_gp002_tx003"], + [], + undefined, + { + expectedTexts: new Map([ + ["pg011_gp002_tx003", "Fecha de nacimiento: ___ / ___ / ___"], + ]), + } + ) + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + + it("preserves the rendered (stripped) text when underscores were dropped", () => { + const html = ` +
+

¡Soy !

+ +
+ ` + const result = validateSectionHtml( + html, + ["pg010_gp009_tx003"], + [], + undefined, + { + expectedTexts: new Map([["pg010_gp009_tx003", "¡Soy ___ !"]]), + } + ) + expect(result.valid).toBe(true) + // Rendered HTML should keep the stripped version, not put the underscores back. + expect(result.sectionHtml).not.toContain("___") + }) + + it("still rejects truly different text even with underscore stripping", () => { + const html = ` +
+

Completely unrelated text here

+ +
+ ` + const result = validateSectionHtml( + html, + ["pg010_gp001_tx001"], + [], + undefined, + { + expectedTexts: new Map([["pg010_gp001_tx001", "Nombre: ___"]]), + } + ) + expect(result.valid).toBe(false) + expect(result.errors).toContainEqual( + expect.stringContaining('Text mismatch for data-id "pg010_gp001_tx001"') + ) + }) +}) + +describe("hasEditableElement — input type filtering", () => { + it("does not count as a writable input", () => { + const html = ` +
+

Question?

+ + +
+ ` + const result = validateSectionHtml(html, ["pg001_gp001"], []) + expect(result.valid).toBe(false) + expect(result.errors).toContainEqual( + expect.stringContaining( + 'Section type "activity_open_ended_answer" requires at least one editable element' + ) + ) + }) + + it("does not count , \"submit\", \"hidden\" as writable", () => { + const html = ` +
+

Label

+ + + +
+ ` + const result = validateSectionHtml(html, ["pg001_gp001"], []) + expect(result.valid).toBe(false) + expect(result.errors).toContainEqual( + expect.stringContaining( + 'Section type "activity_fill_in_the_blank" requires at least one editable element' + ) + ) + }) + + it("counts with no type attribute as writable (defaults to text)", () => { + const html = ` +
+

Name:

+ +
+ ` + const result = validateSectionHtml(html, ["pg001_gp001"], []) + expect(result.valid).toBe(true) + }) + + it("counts as writable", () => { + const html = ` +
+

Name:

+ +
+ ` + const result = validateSectionHtml(html, ["pg001_gp001"], []) + expect(result.valid).toBe(true) + }) + + it("ignores [[blank:item-N]] markers inside +

Label

+ + ` + const result = validateSectionHtml(html, ["pg001_gp001"], []) + expect(result.valid).toBe(false) + expect(result.errors).toContainEqual( + expect.stringContaining( + 'Section type "activity_fill_in_the_blank" requires at least one editable element' + ) + ) + }) +}) + +describe("text replacement preserves nested editables", () => { + it("does not destroy a

+ + ` + const result = validateSectionHtml( + html, + ["pg001_gp001"], + [], + undefined, + { + expectedTexts: new Map([["pg001_gp001", "La capital es ___"]]), + } + ) + expect(result.valid).toBe(true) + // The nested textarea must survive — otherwise the page renders as + // static text and the learner can no longer write an answer. + expect(result.sectionHtml).toContain(" writable element", () => { + const html = ` +
+

Nombre:

+
+ ` + const result = validateSectionHtml( + html, + ["pg001_gp001"], + [], + undefined, + { + expectedTexts: new Map([["pg001_gp001", "Nombre: ___"]]), + } + ) + expect(result.valid).toBe(true) + expect(result.sectionHtml).toContain(" is only counted as an editable element if its type is absent or + * is not in this set. A missing type defaults to "text" per the HTML spec. + */ +const NON_WRITABLE_INPUT_TYPES = new Set([ + "radio", + "checkbox", + "hidden", + "submit", + "button", + "reset", + "image", + "file", + "range", + "color", +]) /** Matches placeholder sequences used in textbooks for blanks (3+ underscores or 3+ dots) */ const TEXTBOOK_BLANK_RE = /_{3,}|\.{3,}/g /** Matches [placeholder:word] markers added during text classification */ @@ -98,6 +121,21 @@ export function validateSectionHtml( walkNode(section, allowedIds, imageIdSet, errors, options) + // Sections whose whole purpose is for the learner to write must contain + // at least one editable element. If the LLM emits only static underlines + // (decorative borders,
s, runs of underscores), the page looks right + // but is unusable — fail loudly so the renderer retries. + const sectionType = section.attribs?.["data-section-type"] + if ( + typeof sectionType === "string" && + WRITABLE_SECTION_TYPES.has(sectionType) && + !hasEditableElement(section) + ) { + errors.push( + `Section type "${sectionType}" requires at least one editable element ( + +``` + +**Multiple inputs per label — date/phone/split-value fields** (use for dates like `"Fecha de nacimiento: ___/___/___"`, `"___ / ___ / ___"`, phone numbers like `"Teléfono: ___-___-___"`, or any source text that visibly splits one prompt across multiple blanks separated by `/`, `-`, or similar connectors): +```html +
+ + + + + + +
+``` + +Hard rules for split-value fields (critical — this is a common failure mode): +- The label inside the `data-id` element contains ONLY the leading label text up to (and optionally including) the colon. It must NOT contain any `/`, `-`, or other separator characters that originally sat between the blanks — those live between the inputs as ``, never inside the label. For `"Fecha de nacimiento: ___/___/___"` the label renders as `Fecha de nacimiento:` — if you render `Fecha de nacimiento: //` or `Fecha de nacimiento: / /` (slashes stranded in the label) the layout is wrong. +- Every input sits in the SAME flex row as the label, interleaved with `` (or `-`) between them. Do NOT put the inputs in a separate grid or row below the label — they must flow next to the label like `Fecha de nacimiento: [d] / [m] / [y]`. +- Use fixed narrow widths (`w-12` / `w-16` / `w-20`) and `text-center` so the inputs look like date/phone cells, not full-width text boxes. +- Emit exactly one input per `___` run in the source text. For `___/___/___` → 3 inputs. For `___-___` → 2 inputs. + +Rules for this pattern: +- The label text stays inside its `data-id` element verbatim — never append a marker or strip words from it +- Every ``/` + - - -
-
-

- Helpful tip here. -

-
- Character + +
+

2. Describe one lesson you learned and why it mattered.

+
@@ -112,6 +131,7 @@ IMPORTANT: Your reasoning MUST be written in English, regardless of the activity - Focus on clear instructions and adequate space for student responses - Use warm colors (amber) to create inviting writing environment - Include helpful tips or scaffolding when available in text IDs +- Count the writable areas on the source page image. The number of editable elements (`