Skip to content

Commit f841803

Browse files
authored
Merge pull request #46 from unicef/nicpottier/package-adt-web
Add ADT web packaging flow and runtime assets
2 parents 52aaf59 + 2805f90 commit f841803

161 files changed

Lines changed: 9351 additions & 3 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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { createProofRoutes } from "./routes/proof.js"
1818
import { createMasterService } from "./services/master-service.js"
1919
import { createMasterRunner } from "./services/master-runner.js"
2020
import { createMasterRoutes } from "./routes/master.js"
21+
import { createPackageRoutes } from "./routes/package.js"
2122

2223
// Resolve paths relative to monorepo root (2 levels up from apps/api/)
2324
const projectRoot = path.resolve(
@@ -28,6 +29,9 @@ const promptsDir = path.resolve(process.env.PROMPTS_DIR ?? path.join(projectRoot
2829
const configPath = path.resolve(
2930
process.env.CONFIG_PATH ?? path.join(projectRoot, "config.yaml")
3031
)
32+
const webAssetsDir = path.resolve(
33+
process.env.WEB_ASSETS_DIR ?? path.join(projectRoot, "assets", "web")
34+
)
3135

3236
const pipelineRunner = createPipelineRunner()
3337
const pipelineService = createPipelineService(pipelineRunner)
@@ -63,5 +67,6 @@ app.route("/api", createMasterRoutes(masterService, booksDir, promptsDir, config
6367
app.route("/api", createGlossaryRoutes(booksDir))
6468
app.route("/api", createDebugRoutes(pipelineService, booksDir, promptsDir, configPath))
6569
app.route("/api", createQuizRoutes(booksDir))
70+
app.route("/api", createPackageRoutes(booksDir, webAssetsDir, configPath))
6671

6772
export default app

apps/api/src/routes/books.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,23 @@ import {
1515
} from "../services/book-service.js"
1616
import { exportBook } from "../services/export-service.js"
1717

18+
const MIME_TYPES: Record<string, string> = {
19+
".html": "text/html; charset=utf-8",
20+
".js": "application/javascript; charset=utf-8",
21+
".css": "text/css; charset=utf-8",
22+
".json": "application/json; charset=utf-8",
23+
".mp3": "audio/mpeg",
24+
".jpg": "image/jpeg",
25+
".jpeg": "image/jpeg",
26+
".png": "image/png",
27+
".svg": "image/svg+xml",
28+
".woff": "font/woff",
29+
".woff2": "font/woff2",
30+
".ttf": "font/ttf",
31+
".ico": "image/x-icon",
32+
".webp": "image/webp",
33+
}
34+
1835
export function createBookRoutes(booksDir: string): Hono {
1936
const app = new Hono()
2037

@@ -217,5 +234,65 @@ export function createBookRoutes(booksDir: string): Hono {
217234
}
218235
})
219236

237+
// GET /books/:label/adt/* — Serve packaged ADT static files
238+
// When no file path is given, redirect to the first page.
239+
app.get("/books/:label/adt/*", (c) => {
240+
const { label } = c.req.param()
241+
let safeLabel: string
242+
try {
243+
safeLabel = parseBookLabel(label)
244+
} catch (err) {
245+
const message = err instanceof Error ? err.message : String(err)
246+
throw new HTTPException(400, { message })
247+
}
248+
const adtDir = path.join(path.resolve(booksDir), safeLabel, "adt")
249+
if (!fs.existsSync(adtDir)) {
250+
throw new HTTPException(404, {
251+
message: `ADT not packaged for book: ${safeLabel}`,
252+
})
253+
}
254+
255+
// Extract file path from URL — c.req.param("*") is unreliable in sub-routers
256+
const adtPrefix = `/books/${safeLabel}/adt/`
257+
const reqPath = c.req.path
258+
const prefixIdx = reqPath.indexOf(adtPrefix)
259+
const filePath = prefixIdx >= 0 ? reqPath.slice(prefixIdx + adtPrefix.length) : ""
260+
if (!filePath) {
261+
// Root request — redirect to first page
262+
const pagesPath = path.join(adtDir, "content", "pages.json")
263+
if (!fs.existsSync(pagesPath)) {
264+
throw new HTTPException(404, {
265+
message: `ADT not packaged for book: ${safeLabel}`,
266+
})
267+
}
268+
const pages = JSON.parse(fs.readFileSync(pagesPath, "utf-8")) as Array<{ href: string }>
269+
if (pages.length === 0) {
270+
throw new HTTPException(404, { message: "ADT has no pages" })
271+
}
272+
return c.redirect(`/api/books/${safeLabel}/adt/${pages[0].href}`)
273+
}
274+
275+
const resolvedPath = path.resolve(adtDir, filePath)
276+
if (!resolvedPath.startsWith(adtDir + path.sep)) {
277+
throw new HTTPException(400, { message: "Invalid path" })
278+
}
279+
280+
let stat: fs.Stats
281+
try {
282+
stat = fs.statSync(resolvedPath)
283+
} catch {
284+
throw new HTTPException(404, { message: `Not found: ${filePath}` })
285+
}
286+
if (!stat.isFile()) {
287+
throw new HTTPException(404, { message: `Not found: ${filePath}` })
288+
}
289+
290+
const fileBuffer = fs.readFileSync(resolvedPath)
291+
const ext = path.extname(resolvedPath).toLowerCase()
292+
c.header("Content-Type", MIME_TYPES[ext] ?? "application/octet-stream")
293+
c.header("Cache-Control", "public, max-age=3600")
294+
return c.body(fileBuffer)
295+
})
296+
220297
return app
221298
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest"
2+
import fs from "node:fs"
3+
import os from "node:os"
4+
import path from "node:path"
5+
import { Hono } from "hono"
6+
import { createBookStorage } from "@adt/storage"
7+
import { errorHandler } from "../middleware/error-handler.js"
8+
import { createPackageRoutes } from "./package.js"
9+
10+
describe("Package routes", () => {
11+
let tmpDir: string
12+
let webAssetsDir: string
13+
let app: Hono
14+
15+
beforeEach(() => {
16+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "package-routes-"))
17+
webAssetsDir = path.join(tmpDir, "web-assets")
18+
fs.mkdirSync(webAssetsDir, { recursive: true })
19+
20+
app = new Hono()
21+
app.onError(errorHandler)
22+
app.route("/api", createPackageRoutes(tmpDir, webAssetsDir))
23+
})
24+
25+
afterEach(() => {
26+
fs.rmSync(tmpDir, { recursive: true, force: true })
27+
})
28+
29+
function createTestBook(label: string): void {
30+
const storage = createBookStorage(label, tmpDir)
31+
storage.close()
32+
}
33+
34+
describe("POST /api/books/:label/package-adt", () => {
35+
it("returns 404 for missing book", async () => {
36+
const res = await app.request("/api/books/missing/package-adt", {
37+
method: "POST",
38+
})
39+
expect(res.status).toBe(404)
40+
})
41+
42+
it("returns 409 when master is not completed", async () => {
43+
createTestBook("book1")
44+
45+
const res = await app.request("/api/books/book1/package-adt", {
46+
method: "POST",
47+
})
48+
49+
expect(res.status).toBe(409)
50+
const body = await res.json()
51+
expect(body.error).toContain("Master phase must be completed")
52+
})
53+
})
54+
55+
describe("GET /api/books/:label/package-adt/status", () => {
56+
it("returns hasAdt=false when pages.json is missing", async () => {
57+
createTestBook("book2")
58+
59+
const res = await app.request("/api/books/book2/package-adt/status")
60+
expect(res.status).toBe(200)
61+
expect(await res.json()).toEqual({ label: "book2", hasAdt: false })
62+
})
63+
64+
it("returns hasAdt=false when pages.json is invalid JSON", async () => {
65+
createTestBook("book3")
66+
const pagesPath = path.join(tmpDir, "book3", "adt", "content", "pages.json")
67+
fs.mkdirSync(path.dirname(pagesPath), { recursive: true })
68+
fs.writeFileSync(pagesPath, "{not-json")
69+
70+
const res = await app.request("/api/books/book3/package-adt/status")
71+
expect(res.status).toBe(200)
72+
expect(await res.json()).toEqual({ label: "book3", hasAdt: false })
73+
})
74+
75+
it("returns hasAdt=false when pages.json has no href entries", async () => {
76+
createTestBook("book4")
77+
const pagesPath = path.join(tmpDir, "book4", "adt", "content", "pages.json")
78+
fs.mkdirSync(path.dirname(pagesPath), { recursive: true })
79+
fs.writeFileSync(pagesPath, JSON.stringify([{ section_id: "pg001" }]))
80+
81+
const res = await app.request("/api/books/book4/package-adt/status")
82+
expect(res.status).toBe(200)
83+
expect(await res.json()).toEqual({ label: "book4", hasAdt: false })
84+
})
85+
86+
it("returns hasAdt=true when pages.json has at least one href entry", async () => {
87+
createTestBook("book5")
88+
const pagesPath = path.join(tmpDir, "book5", "adt", "content", "pages.json")
89+
fs.mkdirSync(path.dirname(pagesPath), { recursive: true })
90+
fs.writeFileSync(
91+
pagesPath,
92+
JSON.stringify([{ section_id: "pg001", href: "pg001.html" }]),
93+
)
94+
95+
const res = await app.request("/api/books/book5/package-adt/status")
96+
expect(res.status).toBe(200)
97+
expect(await res.json()).toEqual({ label: "book5", hasAdt: true })
98+
})
99+
})
100+
})

apps/api/src/routes/package.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import fs from "node:fs"
2+
import path from "node:path"
3+
import { Hono } from "hono"
4+
import { HTTPException } from "hono/http-exception"
5+
import { parseBookLabel } from "@adt/types"
6+
import { createBookStorage } from "@adt/storage"
7+
import { packageAdtWeb, loadBookConfig } from "@adt/pipeline"
8+
9+
export function createPackageRoutes(
10+
booksDir: string,
11+
webAssetsDir: string,
12+
configPath?: string
13+
): Hono {
14+
const app = new Hono()
15+
16+
// POST /books/:label/package-adt — Package the ADT web application
17+
app.post("/books/:label/package-adt", async (c) => {
18+
const { label } = c.req.param()
19+
let safeLabel: string
20+
try {
21+
safeLabel = parseBookLabel(label)
22+
} catch (err) {
23+
const message = err instanceof Error ? err.message : String(err)
24+
throw new HTTPException(400, { message })
25+
}
26+
27+
const resolvedBooksDir = path.resolve(booksDir)
28+
const bookDir = path.join(resolvedBooksDir, safeLabel)
29+
if (!fs.existsSync(path.join(bookDir, `${safeLabel}.db`))) {
30+
throw new HTTPException(404, {
31+
message: `Book not found: ${safeLabel}`,
32+
})
33+
}
34+
35+
if (!fs.existsSync(webAssetsDir)) {
36+
throw new HTTPException(500, {
37+
message: "Web assets directory not found",
38+
})
39+
}
40+
41+
const storage = createBookStorage(safeLabel, booksDir)
42+
try {
43+
const masterStatusRow = storage.getLatestNodeData("master-status", "book")
44+
const masterStatus = masterStatusRow?.data as { status?: string } | undefined
45+
if (masterStatus?.status !== "completed") {
46+
throw new HTTPException(409, {
47+
message: "Master phase must be completed before packaging ADT",
48+
})
49+
}
50+
51+
// Load config for language settings
52+
const config = loadBookConfig(safeLabel, booksDir, configPath)
53+
54+
// Get language from metadata or config
55+
const metadataRow = storage.getLatestNodeData("metadata", "book")
56+
const metadata = metadataRow?.data as {
57+
title?: string | null
58+
language_code?: string | null
59+
} | null
60+
const language =
61+
config.editing_language ?? metadata?.language_code ?? "en"
62+
const outputLanguages =
63+
config.output_languages && config.output_languages.length > 0
64+
? config.output_languages
65+
: [language]
66+
const title = metadata?.title ?? safeLabel
67+
68+
await packageAdtWeb(storage, {
69+
bookDir,
70+
label: safeLabel,
71+
language,
72+
outputLanguages,
73+
title,
74+
webAssetsDir,
75+
})
76+
77+
return c.json({ status: "completed", label: safeLabel })
78+
} catch (err) {
79+
if (err instanceof HTTPException) {
80+
throw err
81+
}
82+
const message = err instanceof Error ? err.message : String(err)
83+
throw new HTTPException(500, {
84+
message: `Packaging failed: ${message}`,
85+
})
86+
} finally {
87+
storage.close()
88+
}
89+
})
90+
91+
// GET /books/:label/package-adt/status — Check if ADT is packaged
92+
app.get("/books/:label/package-adt/status", (c) => {
93+
const { label } = c.req.param()
94+
let safeLabel: string
95+
try {
96+
safeLabel = parseBookLabel(label)
97+
} catch (err) {
98+
const message = err instanceof Error ? err.message : String(err)
99+
throw new HTTPException(400, { message })
100+
}
101+
102+
const bookDir = path.join(path.resolve(booksDir), safeLabel)
103+
const pagesPath = path.join(bookDir, "adt", "content", "pages.json")
104+
const hasAdt = hasPackagedAdtPages(pagesPath)
105+
106+
return c.json({ label: safeLabel, hasAdt })
107+
})
108+
109+
return app
110+
}
111+
112+
function hasPackagedAdtPages(pagesPath: string): boolean {
113+
if (!fs.existsSync(pagesPath)) {
114+
return false
115+
}
116+
117+
try {
118+
const parsed = JSON.parse(fs.readFileSync(pagesPath, "utf-8")) as unknown
119+
if (!Array.isArray(parsed)) {
120+
return false
121+
}
122+
123+
return parsed.some((entry) => {
124+
if (typeof entry !== "object" || entry === null) {
125+
return false
126+
}
127+
const href = (entry as { href?: unknown }).href
128+
return typeof href === "string" && href.length > 0
129+
})
130+
} catch {
131+
return false
132+
}
133+
}

apps/studio/src/api/client.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ function resolveBaseUrl(): string {
1010

1111
const BASE_URL = resolveBaseUrl()
1212

13+
export function getAdtUrl(label: string): string {
14+
return `${BASE_URL}/books/${label}/adt`
15+
}
16+
1317
async function request<T>(path: string, options?: RequestInit): Promise<T> {
1418
const url = `${BASE_URL}${path}`
1519
const res = await fetch(url, {
@@ -392,6 +396,17 @@ export const api = {
392396
getQuizzes: (label: string) =>
393397
request<QuizzesResponse>(`/books/${label}/quizzes`),
394398

399+
packageAdt: (label: string) =>
400+
request<{ status: string; label: string }>(
401+
`/books/${label}/package-adt`,
402+
{ method: "POST" }
403+
),
404+
405+
getPackageAdtStatus: (label: string) =>
406+
request<{ label: string; hasAdt: boolean }>(
407+
`/books/${label}/package-adt/status`
408+
),
409+
395410
exportBook: async (label: string): Promise<Blob> => {
396411
const url = `${BASE_URL}/books/${label}/export`
397412
const res = await fetch(url)

apps/studio/src/components/debug/LlmLogsTab.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const STEP_LABELS: Record<StepName, string> = {
4646
"text-catalog": "Text Catalog",
4747
"catalog-translation": "Translate Catalog",
4848
tts: "Speech",
49+
"package-web": "Package",
4950
}
5051

5152
interface LlmLogsTabProps {

apps/studio/src/components/pipeline/StepIndicator.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const STEP_LABELS: Record<StepName, string> = {
3636
"text-catalog": "Build Text Catalog",
3737
"catalog-translation": "Translate Catalog",
3838
tts: "Generate Speech",
39+
"package-web": "Package ADT",
3940
}
4041

4142
export { STEP_ORDER, STEP_LABELS }

0 commit comments

Comments
 (0)