Skip to content

Commit 5578a45

Browse files
authored
Fix ADT packaging, v2 preview cache-busting, and asset migration (#70)
* Fix ADT packaging: quiz data-id collision, text catalog, preview improvements - Rename assets/web → assets/adt and update all references - Fix quiz flash/disappear bug: use _que suffix for question text catalog IDs to avoid data-id collision between section and question elements - Always build text catalog fresh during packaging; persist only if changed - Build text catalog on-the-fly in preview route for accurate quiz data - Add dynamic quiz page rendering to adt-preview route - Move repackage button from PreviewView content area to sidebar nav - Add page_number to quiz entries in pages.json - Add esbuild JS bundle build step to packaging pipeline - Remove pre-built bundle in favor of esbuild-based bundling * Fix quiz packaging edge case and strengthen package-web tests
1 parent f6edfa0 commit 5578a45

161 files changed

Lines changed: 73720 additions & 371 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/src/app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const configPath = path.resolve(
3838
process.env.CONFIG_PATH ?? path.join(projectRoot, "config.yaml")
3939
)
4040
const webAssetsDir = path.resolve(
41-
process.env.WEB_ASSETS_DIR ?? path.join(projectRoot, "assets", "web")
41+
process.env.WEB_ASSETS_DIR ?? path.join(projectRoot, "assets", "adt")
4242
)
4343

4444
const pipelineRunner = createPipelineRunner()

apps/api/src/routes/adt-preview.ts

Lines changed: 116 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import {
77
WebRenderingOutput,
88
type TextCatalogOutput,
99
type GlossaryOutput,
10+
type QuizGenerationOutput,
1011
type TTSOutput,
12+
type Quiz,
1113
} from "@adt/types"
1214
import { createBookStorage, type Storage } from "@adt/storage"
1315
import {
@@ -18,6 +20,10 @@ import {
1820
buildGlossaryJson,
1921
getBaseLanguage,
2022
normalizeLocale,
23+
renderQuizHtml,
24+
buildQuizAnswers,
25+
buildTextCatalog,
26+
pad3,
2127
} from "@adt/pipeline"
2228

2329
// ---------------------------------------------------------------------------
@@ -44,6 +50,7 @@ const MIME_TYPES: Record<string, string> = {
4450
".wav": "audio/wav",
4551
".ogg": "audio/ogg",
4652
".map": "application/json",
53+
".dic": "application/octet-stream",
4754
}
4855

4956
function getMimeType(filePath: string): string {
@@ -54,7 +61,7 @@ function getMimeType(filePath: string): string {
5461
// Caches (built lazily per book)
5562
// ---------------------------------------------------------------------------
5663

57-
const tailwindCssCache = new Map<string, string>()
64+
// No Tailwind cache — preview is a dev tool; staleness causes more pain than rebuild cost
5865

5966
// ---------------------------------------------------------------------------
6067
// Helpers to read book data from storage
@@ -72,16 +79,21 @@ function getBookTitle(storage: Storage): string {
7279
return metadata?.title ?? "ADT Preview"
7380
}
7481

75-
function getTextCatalog(storage: Storage): TextCatalogOutput | undefined {
76-
const row = storage.getLatestNodeData("text-catalog", "book")
77-
return row?.data as TextCatalogOutput | undefined
82+
function getTextCatalog(storage: Storage): TextCatalogOutput {
83+
const pages = storage.getPages()
84+
return buildTextCatalog(storage, pages)
7885
}
7986

8087
function getGlossary(storage: Storage): GlossaryOutput | undefined {
8188
const row = storage.getLatestNodeData("glossary", "book")
8289
return row?.data as GlossaryOutput | undefined
8390
}
8491

92+
function getQuizData(storage: Storage): QuizGenerationOutput | undefined {
93+
const row = storage.getLatestNodeData("quiz-generation", "book")
94+
return row?.data as QuizGenerationOutput | undefined
95+
}
96+
8597
function buildTextsMap(
8698
storage: Storage,
8799
lang: string,
@@ -109,14 +121,38 @@ function buildTextsMap(
109121
return textsMap
110122
}
111123

112-
/** Build the pages.json manifest from all rendered pages */
124+
/** Build the pages.json manifest from all rendered pages, interleaving quiz pages */
113125
function buildPagesManifest(storage: Storage): Array<{ section_id: string; href: string }> {
114126
const pages = storage.getPages()
127+
const quizData = getQuizData(storage)
128+
129+
// Build a map from afterPageId -> quizzes for interleaving
130+
const quizzesByAfterPageId = new Map<string, Quiz[]>()
131+
if (quizData?.quizzes) {
132+
for (const quiz of quizData.quizzes) {
133+
const existing = quizzesByAfterPageId.get(quiz.afterPageId) ?? []
134+
existing.push(quiz)
135+
quizzesByAfterPageId.set(quiz.afterPageId, existing)
136+
}
137+
}
138+
115139
const list: Array<{ section_id: string; href: string }> = []
116140
for (const page of pages) {
117141
const renderRow = storage.getLatestNodeData("web-rendering", page.pageId)
118-
if (renderRow) {
119-
list.push({ section_id: page.pageId, href: `${page.pageId}.html` })
142+
if (!renderRow) continue
143+
const parsed = WebRenderingOutput.safeParse(renderRow.data)
144+
if (!parsed.success || parsed.data.sections.length === 0) continue
145+
146+
list.push({ section_id: page.pageId, href: `${page.pageId}.html` })
147+
148+
// Insert quiz pages after this page
149+
const quizzes = quizzesByAfterPageId.get(page.pageId)
150+
if (quizzes) {
151+
for (const quiz of quizzes) {
152+
const quizIndex = quizData!.quizzes.indexOf(quiz)
153+
const quizId = `qz${pad3(quizIndex + 1)}`
154+
list.push({ section_id: quizId, href: `${quizId}.html` })
155+
}
120156
}
121157
}
122158
return list
@@ -179,6 +215,12 @@ export function createAdtPreviewRoutes(
179215
): Hono {
180216
const app = new Hono()
181217

218+
// Disable browser caching for all preview responses — this is a dev tool
219+
app.use("*", async (c, next) => {
220+
await next()
221+
c.header("Cache-Control", "no-store")
222+
})
223+
182224
// Helper: resolve book + validate
183225
function resolveBook(label: string) {
184226
const safeLabel = parseBookLabel(label)
@@ -252,31 +294,39 @@ export function createAdtPreviewRoutes(
252294
return c.body(NAV_HTML)
253295
})
254296

255-
// /content/tailwind_output.css
297+
// /content/tailwind_output.css — rebuilt on every request (no cache, dev tool)
256298
app.get("/books/:label/adt-preview/content/tailwind_output.css", async (c) => {
257299
const { safeLabel } = resolveBook(c.req.param("label"))
258300

259-
let css = tailwindCssCache.get(safeLabel)
260-
if (!css) {
261-
const storage = createBookStorage(safeLabel, booksDir)
262-
try {
263-
const pages = storage.getPages()
264-
let allHtml = ""
265-
for (const page of pages) {
266-
const renderRow = storage.getLatestNodeData("web-rendering", page.pageId)
267-
if (renderRow) {
268-
const parsed = WebRenderingOutput.safeParse(renderRow.data)
269-
if (parsed.success) {
270-
const { html } = combineSections(parsed.data)
271-
allHtml += html + "\n"
272-
}
301+
const storage = createBookStorage(safeLabel, booksDir)
302+
let css: string
303+
try {
304+
const pages = storage.getPages()
305+
let allHtml = ""
306+
for (const page of pages) {
307+
const renderRow = storage.getLatestNodeData("web-rendering", page.pageId)
308+
if (renderRow) {
309+
const parsed = WebRenderingOutput.safeParse(renderRow.data)
310+
if (parsed.success) {
311+
const { html } = combineSections(parsed.data)
312+
allHtml += html + "\n"
273313
}
274314
}
275-
css = await buildPreviewTailwindCss(allHtml, webAssetsDir)
276-
tailwindCssCache.set(safeLabel, css)
277-
} finally {
278-
storage.close()
279315
}
316+
317+
// Include quiz HTML so Tailwind scans quiz classes
318+
const quizData = getQuizData(storage)
319+
const catalog = getTextCatalog(storage)
320+
if (quizData?.quizzes) {
321+
for (let i = 0; i < quizData.quizzes.length; i++) {
322+
const quizId = `qz${pad3(i + 1)}`
323+
allHtml += renderQuizHtml(quizData.quizzes[i], quizId, catalog) + "\n"
324+
}
325+
}
326+
327+
css = await buildPreviewTailwindCss(allHtml, webAssetsDir)
328+
} finally {
329+
storage.close()
280330
}
281331

282332
c.header("Content-Type", "text/css")
@@ -393,6 +443,42 @@ export function createAdtPreviewRoutes(
393443
const pageId = filename.replace(/\.html$/, "")
394444

395445
return withStorage(label, (storage) => {
446+
const title = getBookTitle(storage)
447+
const language = getBookLanguage(storage)
448+
449+
// Check if this is a quiz page (qzNNN)
450+
const quizMatch = pageId.match(/^qz(\d{3})$/)
451+
if (quizMatch) {
452+
const quizIndex = parseInt(quizMatch[1], 10) - 1
453+
const quizData = getQuizData(storage)
454+
if (!quizData?.quizzes || quizIndex < 0 || quizIndex >= quizData.quizzes.length) {
455+
throw new HTTPException(404, { message: `No quiz data for: ${pageId}` })
456+
}
457+
const quiz = quizData.quizzes[quizIndex]
458+
const catalog = getTextCatalog(storage)
459+
460+
const quizHtmlContent = renderQuizHtml(quiz, pageId, catalog)
461+
// Determine page index from the manifest
462+
const manifest = buildPagesManifest(storage)
463+
const manifestIndex = manifest.findIndex((e) => e.section_id === pageId)
464+
465+
const html = renderPageHtml({
466+
content: quizHtmlContent,
467+
language,
468+
sectionId: pageId,
469+
pageTitle: title,
470+
pageIndex: manifestIndex >= 0 ? manifestIndex + 1 : 1,
471+
activityAnswers: buildQuizAnswers(quiz, pageId),
472+
hasMath: false,
473+
bundleVersion: "1",
474+
skipContentWrapper: true,
475+
})
476+
477+
c.header("Content-Type", "text/html; charset=utf-8")
478+
return c.body(html)
479+
}
480+
481+
// Regular content page
396482
const renderRow = storage.getLatestNodeData("web-rendering", pageId)
397483
if (!renderRow) {
398484
throw new HTTPException(404, { message: `No rendering data for page: ${pageId}` })
@@ -404,30 +490,17 @@ export function createAdtPreviewRoutes(
404490
}
405491

406492
const { html: sectionHtml, activityAnswers } = combineSections(parsed.data)
407-
const title = getBookTitle(storage)
408-
const language = getBookLanguage(storage)
409493

410-
// Determine page index from all rendered pages
411-
const pages = storage.getPages()
412-
let pageIndex = 1
413-
let idx = 0
414-
for (const p of pages) {
415-
const row = storage.getLatestNodeData("web-rendering", p.pageId)
416-
if (row) {
417-
idx++
418-
if (p.pageId === pageId) {
419-
pageIndex = idx
420-
break
421-
}
422-
}
423-
}
494+
// Determine page index from manifest (includes quiz pages)
495+
const manifest = buildPagesManifest(storage)
496+
const manifestIndex = manifest.findIndex((e) => e.section_id === pageId)
424497

425498
const html = renderPageHtml({
426499
content: sectionHtml,
427500
language,
428501
sectionId: pageId,
429502
pageTitle: title,
430-
pageIndex,
503+
pageIndex: manifestIndex >= 0 ? manifestIndex + 1 : 1,
431504
activityAnswers,
432505
hasMath: false,
433506
bundleVersion: "1",

apps/api/src/routes/books.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const MIME_TYPES: Record<string, string> = {
3131
".ttf": "font/ttf",
3232
".ico": "image/x-icon",
3333
".webp": "image/webp",
34+
".dic": "application/octet-stream",
3435
}
3536

3637
export function createBookRoutes(
@@ -304,6 +305,9 @@ export function createBookRoutes(
304305
})
305306

306307
// GET /books/:label/adt/* — Serve packaged ADT static files
308+
// Supports an optional cache-bust version segment: /adt/v-{ts}/page.html
309+
// The version segment is stripped before resolving files, so all relative
310+
// URLs (pages, assets, content) carry the same bust automatically.
307311
// When no file path is given, redirect to the first page.
308312
app.get("/books/:label/adt/*", (c) => {
309313
const { label } = c.req.param()
@@ -325,7 +329,11 @@ export function createBookRoutes(
325329
const adtPrefix = `/books/${safeLabel}/adt/`
326330
const reqPath = c.req.path
327331
const prefixIdx = reqPath.indexOf(adtPrefix)
328-
const filePath = prefixIdx >= 0 ? reqPath.slice(prefixIdx + adtPrefix.length) : ""
332+
let filePath = prefixIdx >= 0 ? reqPath.slice(prefixIdx + adtPrefix.length) : ""
333+
334+
// Strip optional cache-bust version segment (e.g. "v-1708300000000/" or "v-1708300000000")
335+
filePath = filePath.replace(/^v-[^/]+\/?/, "")
336+
329337
if (!filePath) {
330338
// Root request — redirect to first page
331339
const pagesPath = path.join(adtDir, "content", "pages.json")
@@ -338,8 +346,10 @@ export function createBookRoutes(
338346
if (pages.length === 0) {
339347
throw new HTTPException(404, { message: "ADT has no pages" })
340348
}
341-
const qs = new URL(c.req.url).search
342-
return c.redirect(`/api/books/${safeLabel}/adt/${pages[0].href}${qs}`)
349+
// Preserve the version segment in the redirect
350+
const versionMatch = reqPath.match(/\/adt\/(v-[^/]+)/)
351+
const versionPrefix = versionMatch ? `${versionMatch[1]}/` : ""
352+
return c.redirect(`/api/books/${safeLabel}/adt/${versionPrefix}${pages[0].href}`)
343353
}
344354

345355
const resolvedPath = path.resolve(adtDir, filePath)

apps/studio/src/components/storyboard/PageEditPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -518,7 +518,7 @@ export const PageEditPanel = forwardRef<PageEditPanelHandle, PageEditPanelProps>
518518
</div>
519519
) : adtPreview && combinedHtml ? (
520520
<iframe
521-
src={`/api/books/${label}/adt-preview/${pageId}.html`}
521+
src={`/api/books/${label}/adt/v-${Date.now()}/${pageId}.html`}
522522
className="h-full min-h-[600px] w-full rounded border"
523523
title="ADT Preview"
524524
allow="autoplay"

apps/studio/src/components/v2/StepSidebar.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Languages,
1111
Eye,
1212
Settings,
13+
RotateCcw,
1314
FileDown,
1415
ChevronDown,
1516
Loader2,
@@ -243,8 +244,8 @@ export function StepSidebar({
243244
</span>
244245
</Link>
245246

246-
{/* Settings gear icon (only for active step, not for book) */}
247-
{isActive && step.slug !== "book" && (
247+
{/* Action button (only for active step, not for book) */}
248+
{isActive && step.slug !== "book" && step.slug !== "preview" && (
248249
<Button
249250
variant="ghost"
250251
size="icon"
@@ -266,6 +267,20 @@ export function StepSidebar({
266267
</Link>
267268
</Button>
268269
)}
270+
{isActive && step.slug === "preview" && (
271+
<Button
272+
variant="ghost"
273+
size="icon"
274+
className={cn(
275+
"w-6 h-6 rounded shrink-0 hidden lg:inline-flex group-hover/sidebar:inline-flex [&_svg]:size-3.5",
276+
"hover:bg-black/5 text-current opacity-50 hover:opacity-100"
277+
)}
278+
onClick={() => window.dispatchEvent(new CustomEvent("adt:repackage"))}
279+
title="Re-package ADT"
280+
>
281+
<RotateCcw className="w-3.5 h-3.5" />
282+
</Button>
283+
)}
269284
</div>
270285

271286
{/* Settings sub-tabs (extract only, when settings active) */}

0 commit comments

Comments
 (0)