From 8b021336a635d22f8d9099b5a58aa7598cc5cbd5 Mon Sep 17 00:00:00 2001 From: daTechGuy Date: Sat, 3 Oct 2026 16:29:52 -0500 Subject: [PATCH] Add Canvas test harness, TypeScript IMSCC validator, real-Canvas fixtures, refreshed CLAUDE.md The knowledge from testing exports against a live Canvas lived only in ad-hoc scripts under the gitignored output/ folder. Make it durable and enforce it in CI. Validator - src/services/export/validateImscc.ts: offline structural validator, ported from the Python prototype (which is removed). Native-Canvas-format rules catch the failure that "imports cleanly" but drops content: marker without module_meta.xml, quiz meta resource without native QTI, wiki_content page not in the manifest (stray page), dangling module/organization references, malformed XML, bare "&". Never imported by the app, so not bundled. CLI: npm run validate:imscc. - tests/imscc-validator.test.ts: passes Canvas's own exports (no false positives) and our output; fails each corruption with a specific message. - tests/canvas-real-fixtures.test.ts + tests/fixtures/*.imscc: our parser and template exporter run against a reference course and an instructor template written by Canvas's OWN exporter (scrubbed of instance details). Locks in the behaviours that were bugs (href-less discussion resources, verbatim external links, no stray pages). .gitignore excepts these fixtures from "*.imscc". CI already runs the whole suite, so these now guard every PR. Harness: tools/canvas-test/ - setup-canvas.sh: reproducible, resumable, idempotent Canvas build pinned to exact Canvas and QTIMigrationTool commits, with the three fixes baked in (publish PORT:80, undeclared @instructure/platform-alerts dependency, QTI tool before first boot). `fixes` and `smoke` subcommands; verified idempotent on the live VM. - cct.sh (+ ruby/): import a cartridge into a throwaway course and dump what Canvas actually built; build reference/template courses with Canvas's own exporter; list/cleanup (only touches courses ending " (cct)"). Local or over SSH. - build-demo-cartridge.sh (+ gated tests/build-demo-cartridge.test.ts). - README documenting the workflow and every pitfall. .gitattributes pins *.sh / *.rb to LF (core.autocrlf would otherwise break them on Windows checkouts) and marks *.imscc binary. CLAUDE.md rewritten for this fork: LLM/provider layer, the two export modes and the rules that bit us, verification workflow, merge-commit-only and upstream-sync notes. README validator reference fixed. Co-Authored-By: Claude Sonnet 5.5 --- .gitattributes | 6 + .gitignore | 2 + CLAUDE.md | 185 ++++++------ README.md | 2 +- package.json | 3 +- src/services/export/validateImscc.ts | 264 ++++++++++++++++++ tests/build-demo-cartridge.test.ts | 56 ++++ tests/canvas-real-fixtures.test.ts | 169 +++++++++++ .../fixtures/canvas-instructor-template.imscc | Bin 0 -> 11666 bytes tests/fixtures/canvas-reference-export.imscc | Bin 0 -> 20408 bytes tests/imscc-validator.test.ts | 183 ++++++++++++ tools/canvas-test/README.md | 93 ++++++ tools/canvas-test/build-demo-cartridge.sh | 11 + tools/canvas-test/cct.sh | 91 ++++++ tools/canvas-test/ruby/delete_test_courses.rb | 11 + tools/canvas-test/ruby/import_cartridge.rb | 50 ++++ tools/canvas-test/ruby/inspect_import.rb | 76 +++++ tools/canvas-test/ruby/list_courses.rb | 4 + .../canvas-test/ruby/make_reference_course.rb | 74 +++++ .../canvas-test/ruby/make_template_course.rb | 90 ++++++ tools/canvas-test/setup-canvas.sh | 197 +++++++++++++ tools/validate-imscc.py | 211 -------------- tools/validate-imscc.ts | 32 +++ 23 files changed, 1508 insertions(+), 302 deletions(-) create mode 100644 .gitattributes create mode 100644 src/services/export/validateImscc.ts create mode 100644 tests/build-demo-cartridge.test.ts create mode 100644 tests/canvas-real-fixtures.test.ts create mode 100644 tests/fixtures/canvas-instructor-template.imscc create mode 100644 tests/fixtures/canvas-reference-export.imscc create mode 100644 tests/imscc-validator.test.ts create mode 100644 tools/canvas-test/README.md create mode 100755 tools/canvas-test/build-demo-cartridge.sh create mode 100755 tools/canvas-test/cct.sh create mode 100644 tools/canvas-test/ruby/delete_test_courses.rb create mode 100644 tools/canvas-test/ruby/import_cartridge.rb create mode 100644 tools/canvas-test/ruby/inspect_import.rb create mode 100644 tools/canvas-test/ruby/list_courses.rb create mode 100644 tools/canvas-test/ruby/make_reference_course.rb create mode 100644 tools/canvas-test/ruby/make_template_course.rb create mode 100755 tools/canvas-test/setup-canvas.sh delete mode 100644 tools/validate-imscc.py create mode 100644 tools/validate-imscc.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..bbe4bdf --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Shell and Ruby scripts must keep LF even on Windows checkouts (core.autocrlf=true would +# otherwise give them CRLF and bash/ruby fail with "\r: command not found"). +*.sh text eol=lf +*.rb text eol=lf +# Canvas cartridges are zip archives. +*.imscc binary diff --git a/.gitignore b/.gitignore index 454fc71..8278b95 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,8 @@ deploy-courses/ # Local template uploads *.imscc *.docx +# ...except the scrubbed, Canvas-made cartridges the tests run against +!tests/fixtures/*.imscc # Design scratch + internal references (not for the public repo) ClassBuild Redesign/ diff --git a/CLAUDE.md b/CLAUDE.md index b56a30f..3355689 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,108 +1,98 @@ -# ClassBuild +# CanvasClassBuild -AI course generator that turns a topic description into a complete, multimedia university course. React 19 SPA with a Node CLI, BYOK (bring your own key) — runs entirely client-side. +A fork of [ClassBuild](https://github.com/jtangen/classbuild) (Jason Tangen) retargeted at Canvas LMS: AI-assisted course generation that exports a **Canvas-importable course** (`.imscc`). React 19 SPA with a Node CLI, BYOK (bring your own key) — runs entirely client-side. + +Upstream is remote `upstream` (`jtangen/classbuild`); this fork is `origin` (`daTechGuy/CanvasClassBuild`). ## Quick commands ```bash -npm run dev # Start dev server at localhost:5173 -npm run build # TypeScript check (tsc -b) + Vite build -npm run lint # ESLint +npm run dev # dev server at localhost:5173 +npm run build # tsc -b + vite build (tsc only type-checks src/, NOT tests/) +npm run lint # ESLint over everything (7 known react-hooks/exhaustive-deps warnings, all pre-existing upstream) +npm test # vitest (happy-dom). CI runs `test:coverage` +npm run validate:imscc -- file.imscc # offline structural check of a Canvas cartridge -# CLI course generation -ANTHROPIC_API_KEY=sk-... npx tsx scripts/generate-course.ts \ - --topic "Your Topic" --chapters 12 --output ./output/dir +# Canvas-importable course from a topic + a Canvas template, headless +npx tsx scripts/canvas-course.ts --topic "..." --template ./template.imscc --chapters 8 --output ./out +# Upstream's generic generator +ANTHROPIC_API_KEY=sk-... npx tsx scripts/generate-course.ts --topic "Your Topic" --chapters 12 --output ./out ``` +CI (`.github/workflows/ci.yml`): `tsc -b` → lint → `test:coverage` → build. `main` is branch-protected with the **"Build + Test"** check required. + +## The two Canvas export modes + +| | No template | Template-based | +|---|---|---| +| Entry | `assembleImscc()` — `src/services/export/imsccExporter.ts` | `assembleTemplateImscc()` — `src/services/template/templateImsccExporter.ts` | +| Input | the generated course | a Canvas export (`.imscc`) uploaded as a structural template + generated "Canvas Module" content | +| Output | a fresh **native Canvas export** | the template with its verbatim modules intact and pattern modules replaced | + +### Rules that are easy to get wrong (all learned from real imports) + +- **The `course_settings/canvas_export.txt` marker changes how Canvas reads the package.** Canvas classifies such a package as a *native Canvas export* and reads **only** Canvas's own files — `course_settings/module_meta.xml`, `wiki_content/`, `non_cc_assessments/*.xml.qti`, `/assessment_meta.xml` — and **ignores plain Common Cartridge manifest content**. A cartridge with the marker but without those files reports "imported, no issues" and contains only the syllabus and discussions. So: marker ⇒ must emit the full native layout (the no-template exporter does). +- **Quizzes need both QTI flavours** (`/assessment_qti.xml` for the CC profile *and* `non_cc_assessments/.xml.qti` with Canvas metadata: `question_type`, `points_possible`, `original_answer_ids`, `assessment_question_identifierref`) plus `assessment_meta.xml`. Graded quizzes also need an `` block referencing an assignment group; the practice quiz is an ungraded `practice_quiz`. +- **Canvas imports every file in `wiki_content/`**, listed in the manifest or not. When the template exporter drops a pattern module, it must delete that module's files (and the metadata resources they depend on) from the archive, or they become stray duplicate pages. +- **Verbatim template modules are passed through byte-for-byte** (`extractRawModuleBlocks`); only `` is renumbered. The parser keeps only a subset of module/item fields, so re-serialising loses things (an external link's ``, LTI link settings, prerequisites, completion requirements). Don't "simplify" this back. +- **Preserve `` links and `intendeduse` on manifest resources** — quizzes/discussions lose their metadata without them. +- **In Canvas's own exports, discussion/quiz resources have no `href` attribute** (the file is only in a `` child), sub-headers have no `identifierref`, and external-URL items carry their URL inline with an `identifierref` that matches no resource. Parsers and validators must not assume otherwise. +- **Escape XML and HTML separately** (`escXml` vs `escHtml`); a bare `&` is tolerated by DOM parsers but not by Canvas. +- Identifiers are deterministic (`gid(seed)`), so re-exporting the same course gives the same ids. +- Slide decks are image-driven (one gpt-image-2 render per slide) and rendered images are **not persisted** across reloads. The cartridge only includes `slides.pptx` if every slide is already rendered, or the user explicitly opted in (the Export page asks) — an export click must never silently spend image credits. +- The weekly challenge exports only multiple-choice-shaped questions; other types (assertion-reason, agreement-matrix, slider) are dropped. + +### Verifying an export change (do this — don't trust "imported") + +1. `npm test` — includes `tests/imscc-validator.test.ts` and `tests/canvas-real-fixtures.test.ts`, which run the validator, parser and template exporter against **real Canvas-made exports** in `tests/fixtures/`. +2. For anything touching `src/services/export/` or `src/services/template/`: import into a real Canvas and *read what it built* — see [`tools/canvas-test/README.md`](tools/canvas-test/README.md) (`cct.sh import …`). CI cannot do this step. + ## Project structure ``` src/ -├── main.tsx # React entry point -├── App.tsx # Router (6 routes) -├── themes.ts # 4 themes + buildThemePromptBlock() -├── index.css # Global styles (Tailwind) -├── types/ -│ ├── course.ts # All data interfaces -│ └── generation.ts # Generation-related types -├── store/ -│ ├── courseStore.ts # Course state (persisted, Zustand) -│ ├── apiStore.ts # API keys (persisted) -│ └── uiStore.ts # Batch generation UI state -├── prompts/ # 12 prompt builders (see below) -│ ├── syllabus.ts -│ ├── chapter.ts -│ ├── research.ts -│ ├── slides.ts -│ ├── practiceQuiz.ts -│ ├── inClassQuiz.ts -│ ├── activities.ts -│ ├── discussion.ts -│ ├── audioTranscript.ts -│ ├── learningObjectives.ts -│ ├── infographic.ts -│ └── weeklyChallenge.ts -├── services/ -│ ├── claude/ -│ │ ├── client.ts # Anthropic SDK wrapper, model IDs, thinking budgets -│ │ └── streaming.ts # streamMessage() — streaming with web search + thinking -│ ├── gemini/ -│ │ ├── imageGen.ts # Gemini image generation (dynamic import) -│ │ ├── imagePlacer.ts # Replace image placeholders in HTML -│ │ └── tts.ts # Gemini text-to-speech (dynamic import) -│ ├── quiz/answerBalancer.ts # Balance correct/distractor answers -│ └── export/ -│ ├── pptxExporter.ts # PowerPoint (dynamic import) -│ ├── quizDocExporter.ts # DOCX quiz export -│ └── publishExporter.ts # Course viewer assembly (dynamic import) -├── templates/ -│ ├── quizTemplate.ts # Gamified quiz HTML (~2400 lines, dynamic import) -│ ├── weeklyChallengeTemplate.ts # Weekly mastery challenge HTML (dynamic import) -│ └── courseViewerTemplate.ts # Standalone course viewer -├── pages/ -│ ├── LandingPage.tsx # Landing with example courses -│ ├── SetupPage.tsx # Stage 1: topic & config -│ ├── SyllabusPage.tsx # Stage 2: syllabus generation -│ ├── ResearchPage.tsx # Stage 3: research dossiers -│ ├── BuildPage.tsx # Stage 4: two-panel chapter build -│ └── ExportPage.tsx # Stage 5: download/publish +├── App.tsx # Router — 7 routes (/, /setup, /syllabus, /research, /build, /export, /templates) +├── index.css # Tailwind @theme + Codex CSS variables (--cb-*); accent overridden to Canvas red +├── types/ # course.ts (all data interfaces), template.ts, outline.ts, generation.ts +├── store/ # Zustand +│ ├── courseStore.ts # course state (persisted to IndexedDB via idbStorage) +│ ├── apiStore.ts # API keys, provider, research backend, advancedMode (persisted) +│ ├── templateStore.ts # uploaded Canvas templates (parsed + raw .imscc blob) +│ └── uiStore.ts # transient UI/generation state, in-flight map +├── pages/ # Landing, Setup, Syllabus, Research, Build, Export, TemplatePreview ├── components/ -│ ├── layout/ # AppShell, Header, StageIndicator -│ ├── shared/ # Button, Card, Spinner, StreamingText, etc. -│ ├── setup/ # TopicInput, AudienceSelector, ApiKeyPanel, etc. -│ ├── syllabus/ # SyllabusTimeline, ChapterCard, CurriculumMapPanel -│ └── build/ # ChapterSidebar, ResearchPanel -└── utils/ - └── doiValidator.ts -scripts/ -├── generate-course.ts # Main CLI entry point -├── gen-audio.ts # Audio-only CLI -├── publish-course.ts # Publish utility -├── package-scorm.ts # SCORM 2004 ZIP packager for Blackboard -└── lib/ # CLI-specific helpers -public/ # Static assets (hero, previews, course thumbnails) +│ ├── codex/ # the design system (CodexButton, CodexModal, ChoicePill…) +│ ├── build/ # Build page: tabs/*, useChapterMaterials hook, ArtifactShell, ChapterSidebar +│ ├── setup/, syllabus/, export/, layout/, shared/ +├── prompts/ # prompt builders (syllabus, chapter, quizzes, slides, templateChapter…) +├── services/ +│ ├── llm/ # provider router: anthropic.ts, ollama.ts, index.ts (the real streaming layer) +│ ├── claude/ # SDK client, model ids, thinking budgets; streaming.ts is a thin facade over llm/ +│ ├── research/ # backends: anthropic (web search), tavily, wikipedia; runResearch() dispatch +│ ├── academic/ # Crossref / Semantic Scholar / Unpaywall source enrichment +│ ├── openai/, elevenLabs/ # images (gpt-image-2) and narration +│ ├── export/ # imsccExporter, validateImscc, pptx/quizDoc/teachingResources/publish exporters +│ ├── template/ # parser, templateImsccExporter, generateChapter, parseOutlineDocx +│ ├── quiz/, abortRegistry.ts +├── templates/, themes/, fixtures/ (demoCourse), utils/ +api/ollama-proxy.ts # Vercel Edge proxy for Ollama Cloud (CORS) +scripts/ # CLIs: canvas-course.ts, generate-course.ts, gen-audio.ts, package-scorm.ts … +tools/ # validate-imscc.ts (CLI), canvas-test/ (Canvas test harness) +tests/ # vitest; fixtures/ holds real Canvas-made .imscc files ``` ## Key conventions -- `tsconfig.app.json` has `erasableSyntaxOnly: true` — no parameter properties (`public readonly x` in constructors). Use class field declarations instead. -- `noUnusedLocals: true` and `noUnusedParameters: true` — strict unused checks. Always test with `npm run build`. -- Web search uses server tool type: `{ type: 'web_search_20250305', name: 'web_search' }`, NOT a custom tool_use block. -- Stream events: `server_tool_use` for search queries, `web_search_tool_result` for results. -- Template literal escaping: `\${x}` prevents interpolation. For nested templates, `` \` `` produces a literal backtick. -- All code-split chunks use dynamic `import()`: tts.ts, pptxExporter.ts, quizTemplate.ts, weeklyChallengeTemplate.ts, imageGen.ts, imagePlacer.ts, infographic.ts, publishExporter.ts. -- Themes propagate to all outputs via `buildThemePromptBlock(theme)` which injects CSS variables into prompts. -- 3 Zustand stores: courseStore (persisted, version 1 with stage migration), apiStore (persisted), uiStore (transient). -- Anthropic SDK version 0.74.0, used with `dangerouslyAllowBrowser: true` for BYOK. - -## How to add a new material type - -1. Create a prompt builder in `src/prompts/newMaterial.ts` — export `buildNewMaterialPrompt(themeId?)` returning a system prompt string and `buildNewMaterialUserPrompt(chapter, syllabus, setup, research?)` returning a user message string. -2. Add the generated field to `GeneratedChapter` in `src/types/course.ts`. -3. In `src/pages/BuildPage.tsx`, add a generation step that calls `streamMessage()` with your prompt and stores the result via `courseStore.updateChapter()`. -4. If needed, add an export handler in `src/services/export/`. -5. For the CLI, import your prompt builder in `scripts/generate-course.ts` and add a generation step in the per-chapter pipeline. -6. Run `npm run build` to verify no type errors. +- `tsconfig.app.json` has `erasableSyntaxOnly: true` — no parameter properties; use class field declarations. `noUnusedLocals` / `noUnusedParameters` are on — always run `npm run build`. +- **`tsc -b` only checks `src/`.** Test files are transpiled by vitest but not type-checked; keep them type-correct anyway. +- **Providers (Claude / Ollama Cloud) go through `src/services/llm/`.** `apiStore.provider` picks the backend. Call sites pass Anthropic model ids (`MODELS.opus` …); `resolveProvider` **ignores any `claude-*` model id on the Ollama path** and uses `apiStore.ollamaModel`. Never forward a Claude model id to Ollama. +- **Opus 4.8** uses adaptive thinking (`thinking: {type:'adaptive'}` + `output_config.effort`); manual `budget_tokens` is rejected on Opus ≥4.7. Sonnet/Haiku still use manual budgets. Pass `signal` for cancellation and don't retry an aborted call. +- **`advancedMode`** (apiStore) is "Canvas-focused" (Reading / Practice / Quizzes / Discussion) vs "Everything" (+ Challenge / Activities / Audio / Slides). It gates the visible tabs **and** batch / "Generate all" generation **and** the sidebar's N-of-M count — keep them in step. +- **Don't put an inline `onClose` in an effect dependency list.** `CodexModal` once re-focused the dialog on every keystroke because its effect depended on the caller's inline arrow; it now holds `onClose` in a ref. Same trap anywhere an effect side-effect (focus, scroll) follows a changing callback. +- Web search is a *server tool*: `{ type: 'web_search_20250305', name: 'web_search' }`, not a custom `tool_use` block. Stream events: `server_tool_use` for queries, `web_search_tool_result` for results. +- Template-literal escaping: `\${x}` prevents interpolation; for nested templates `` \` `` yields a literal backtick. +- Heavy modules are code-split with dynamic `import()` (pptx, quiz/challenge templates, image gen, publish exporter, docx). +- Anthropic SDK 0.74.0 with `dangerouslyAllowBrowser: true` (BYOK). ## Models @@ -113,4 +103,21 @@ sonnet: 'claude-sonnet-4-6' haiku: 'claude-haiku-4-5-20251001' ``` -Syllabus and chapters use Opus with extended thinking. Slides, quizzes, and other materials use Sonnet. Research uses Haiku with web search. +Syllabus and chapters use Opus with thinking; slides, quizzes and other materials use Sonnet; research uses Haiku with web search. + +## How to add a new material type + +1. Prompt builder in `src/prompts/newMaterial.ts` — `buildNewMaterialPrompt(themeId?)` and `buildNewMaterialUserPrompt(...)`. +2. Add the generated field to `GeneratedChapter` in `src/types/course.ts`. +3. Generation: add a generator to `src/components/build/useChapterMaterials.ts` (stream via `streamMessage()` from `services/llm`, store with `courseStore.updateChapter()`), a tab component in `src/components/build/tabs/`, and wire the tab into `BuildPage.tsx` (the `tabs` array, the `activeTab` render switch, `SHORTCUT_TAB_IDS`). Decide whether it is advanced-only (`advancedMode`) and gate it everywhere (tab, batch, "Generate all", sidebar count). +4. Export: handle it in `src/services/export/` — and in `imsccExporter.ts` if it should reach Canvas (follow the native-format rules above), with a test. +5. CLI: import the prompt builder in `scripts/generate-course.ts` and add a step to the per-chapter pipeline. +6. `npm run build`, `npm test`. + +## Working with git here (gotchas) + +- **Merge PRs with a merge commit — never squash or rebase.** Upstream's history is merged into `main`; squashing discards that ancestry and the next upstream sync conflicts again. +- **Syncing upstream:** `git fetch upstream`, merge `upstream/main` on a branch, resolve, run `tsc -b` + lint + `npm test`, then (if export code changed) the Canvas harness. The gap grew to 54/31 commits and a full UI redesign once — sync often. +- Branch protection requires the "Build + Test" check; GitHub won't let the PR author approve their own PR. +- LF→CRLF warnings on Windows are expected (`core.autocrlf=true`, no `.gitattributes`). +- Keep generated cartridges and scratch under the gitignored `output/`. diff --git a/README.md b/README.md index ce06833..aadad46 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ services, setup components). Pages — `BuildPage`, `SyllabusPage`, Without an uploaded template, "Export for Canvas (.imscc)" writes a **native Canvas course export** — the same file layout Canvas's own exporter produces (`module_meta.xml`, `wiki_content/` pages, `non_cc_assessments/` quizzes, `assessment_meta.xml`, `assignment_groups.xml`, syllabus). This matters: a package carrying Canvas's `course_settings/canvas_export.txt` marker is read by Canvas's *native* importer, which silently ignores plain Common Cartridge manifest content, so a "generic" cartridge with that marker imports with no modules, pages or quizzes. -Check any cartridge offline with `python tools/validate-imscc.py file.imscc` (it flags exactly that class of problem). To test imports end to end you need a Canvas with Instructure's QTIMigrationTool installed, otherwise quizzes are skipped silently. +Check any cartridge offline with `npm run validate:imscc -- file.imscc` (it flags exactly that class of problem and runs in CI against real Canvas-made fixtures). To test an import end to end, use the Canvas harness in [`tools/canvas-test/`](tools/canvas-test/README.md) — it builds a dev Canvas (with Instructure's QTIMigrationTool; without it quizzes are skipped silently) and shows what Canvas actually built. Current coverage: - `tests/template-parser.test.ts` — module classification (verbatim / pattern / example-pattern), prefix detection (`Module N:`, `MN Instructor Notes:`, fully-locked `Module N Overview`), `(Example to Edit)` placeholder marker, `**EDIT**` markers, example-pattern content extraction. diff --git a/package.json b/package.json index 263d6eb..e6c998e 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "validate:imscc": "tsx tools/validate-imscc.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.74.0", diff --git a/src/services/export/validateImscc.ts b/src/services/export/validateImscc.ts new file mode 100644 index 0000000..5b279eb --- /dev/null +++ b/src/services/export/validateImscc.ts @@ -0,0 +1,264 @@ +/** + * Offline structural validation of a Common Cartridge (.imscc) destined for + * Canvas. Not imported by the app (so it is never bundled) — it is used by the + * test suite, by `tools/validate-imscc.ts`, and could back an in-app "check + * before download" later. + * + * Why this exists: Canvas happily reports "imported, no issues" for a cartridge + * that silently drops content. A package carrying `course_settings/canvas_export.txt` + * is classified as a *native Canvas export*, and Canvas's native converter reads + * only Canvas's own files (module_meta.xml, wiki_content/, non_cc_assessments/…) + * while ignoring plain manifest content. These rules catch that class of problem + * without needing a live Canvas. + * + * This is a structural check only — it cannot prove Canvas will accept a package. + * An import into a real Canvas (see tools/canvas-test/) remains the source of truth. + */ +import JSZip from 'jszip'; + +export interface ValidationReport { + /** Hard errors: Canvas will drop or mangle content. */ + problems: string[]; + /** Things worth a look but not fatal. */ + warnings: string[]; + info: string[]; + ok: boolean; +} + +export type ImsccInput = Uint8Array | ArrayBuffer | Blob; + +const NATIVE_MARKER = 'course_settings/canvas_export.txt'; +const QTI_RESOURCE = 'imsqti_xmlv1p2/imscc_xmlv1p1/assessment'; +const RESOURCE_BACKED_ITEMS = new Set(['WikiPage', 'Quizzes::Quiz', 'DiscussionTopic', 'Attachment', 'Assignment']); +const KNOWN_ITEM_TYPES = new Set([ + ...RESOURCE_BACKED_ITEMS, + 'ExternalUrl', + 'ContextExternalTool', + 'ContextModuleSubHeader', +]); +const KNOWN_RESOURCE_TYPE = + /^(webcontent|imsdt_xmlv1p\d|imsqti_xmlv1p\d\/imscc_xmlv1p\d\/assessment|associatedcontent\/imscc_xmlv1p\d\/learning-application-resource|imsbasiclti_xmlv1p\d|imswl_xmlv1p\d|imsqti_xmlv1p\d\/.*)$/; + +function local(el: Element): string { + return el.localName || el.tagName.replace(/^.*:/, ''); +} + +function descendants(root: Document | Element, name: string): Element[] { + return Array.from(root.getElementsByTagName('*')).filter((e) => local(e) === name); +} + +function childText(el: Element, name: string): string | null { + const c = Array.from(el.children).find((x) => local(x) === name); + return c ? (c.textContent ?? '') : null; +} + +function parseXml(text: string): Document | null { + try { + const doc = new DOMParser().parseFromString(text, 'text/xml'); + if (!doc || doc.getElementsByTagName('parsererror').length > 0) return null; + return doc; + } catch { + return null; + } +} + +export async function validateImscc(input: ImsccInput): Promise { + const problems: string[] = []; + const warnings: string[] = []; + const info: string[] = []; + + let zip: JSZip; + try { + zip = await JSZip.loadAsync(input as Blob); + } catch (e) { + return { problems: [`not a readable zip archive: ${(e as Error).message}`], warnings, info, ok: false }; + } + + const names = Object.keys(zip.files).filter((n) => !zip.files[n].dir); + const nameSet = new Set(names); + const read = (n: string) => zip.file(n)!.async('string'); + + // 1. Archive basics + if (!nameSet.has('imsmanifest.xml')) { + return { problems: ['imsmanifest.xml missing at archive root'], warnings, info, ok: false }; + } + const backslashed = names.filter((n) => n.includes('\\')); + if (backslashed.length) problems.push(`backslash paths in archive: ${backslashed.slice(0, 3).join(', ')}`); + + // 2. Every XML part parses + let xmlOk = 0; + for (const n of names) { + if (/\.(xml|qti)$/.test(n)) { + const text = await read(n); + // DOM parsers are lenient about a bare "&" (e.g. a title like "R&D"), but Canvas's + // XML parser is not — check for it explicitly. + if (/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)/.test(text)) { + problems.push(`unescaped "&" in XML: ${n}`); + } else if (parseXml(text)) xmlOk++; + else problems.push(`not well-formed XML: ${n}`); + } + } + info.push(`${xmlOk} XML files parse cleanly; ${names.length} entries total`); + + // 3. Manifest integrity + const manifestDoc = parseXml(await read('imsmanifest.xml')); + if (!manifestDoc) return { problems: [...problems, 'imsmanifest.xml is not well-formed'], warnings, info, ok: false }; + const man = manifestDoc.documentElement; + info.push(`manifest namespace: ${man.getAttribute('xmlns') ?? '(none)'}`); + + const resources = new Map(); + for (const r of descendants(man, 'resource')) resources.set(r.getAttribute('identifier') ?? '', r); + + const allIds: string[] = []; + for (const e of descendants(man, 'resource')) allIds.push(e.getAttribute('identifier') ?? ''); + for (const e of descendants(man, 'item')) if (e.getAttribute('identifier')) allIds.push(e.getAttribute('identifier')!); + const counts = new Map(); + for (const id of allIds) counts.set(id, (counts.get(id) ?? 0) + 1); + const dupIds = [...counts].filter(([, c]) => c > 1).map(([id]) => id); + if (dupIds.length) problems.push(`duplicate identifiers in manifest: ${dupIds.slice(0, 5).join(', ')}`); + + const claimed = new Set(); + const missingFiles: string[] = []; + for (const [rid, r] of resources) { + const hrefs = [r.getAttribute('href'), ...descendants(r, 'file').map((f) => f.getAttribute('href'))].filter( + (h): h is string => !!h, + ); + for (const h of hrefs) { + claimed.add(h); + if (!nameSet.has(h)) missingFiles.push(`${rid}→${h}`); + } + } + if (missingFiles.length) { + problems.push( + `${missingFiles.length} manifest references to files not in the archive, e.g. ${missingFiles.slice(0, 3).join(', ')}`, + ); + } + + // Organization items must point at real resources (empty = no resource, e.g. a sub-header). + const orgBad: string[] = []; + let orgItems = 0; + for (const item of descendants(man, 'item')) { + orgItems++; + const ref = item.getAttribute('identifierref'); + if (ref && !resources.has(ref)) orgBad.push(ref); + } + if (orgBad.length) problems.push(`item identifierref pointing at unknown resources: ${orgBad.slice(0, 5).join(', ')}`); + info.push(`${resources.size} resources, ${orgItems} organization items`); + + const unclaimed = names.filter((n) => n !== 'imsmanifest.xml' && !claimed.has(n)); + if (unclaimed.length) { + warnings.push( + `${unclaimed.length} archive files not claimed by any resource (Canvas ignores them): ${unclaimed.slice(0, 4).join(', ')}`, + ); + } + + // 4. Resource types + const typeCounts = new Map(); + for (const r of resources.values()) { + const t = r.getAttribute('type') ?? ''; + typeCounts.set(t, (typeCounts.get(t) ?? 0) + 1); + if (!KNOWN_RESOURCE_TYPE.test(t)) warnings.push(`unrecognised resource type for ${r.getAttribute('identifier')}: ${t}`); + } + info.push('resource types: ' + [...typeCounts].map(([k, v]) => `${k} x${v}`).join(', ')); + + // 5. QTI sanity + let qtiFiles = 0; + for (const n of names.filter((x) => /\.(xml|qti)$/.test(x))) { + const text = await read(n); + if (!text.includes(' e.getAttribute('identifier'))); + const modItems = descendants(mm, 'item'); + for (const it of modItems) { + const ref = childText(it, 'identifierref'); + const ctype = childText(it, 'content_type') ?? ''; + if (RESOURCE_BACKED_ITEMS.has(ctype) && (!ref || !resources.has(ref))) { + problems.push(`module_meta ${ctype} item points at unknown resource ${ref}`); + } + if (!orgIds.has(it.getAttribute('identifier'))) { + warnings.push(`module_meta item ${it.getAttribute('identifier')} is not in the manifest organization`); + } + if (!KNOWN_ITEM_TYPES.has(ctype)) warnings.push(`unusual module item content_type ${ctype}`); + } + info.push(`${descendants(mm, 'module').length} modules, ${modItems.length} module items in module_meta.xml`); + } + } + + for (const n of names.filter((x) => x.startsWith('wiki_content/') && x.endsWith('.html'))) { + const body = await read(n); + if (!claimed.has(n)) { + problems.push( + `${n}: not listed in the manifest, but the native importer scans wiki_content/ directly: it would be imported as a stray page`, + ); + } + if (!body.includes('name="identifier"')) { + problems.push(`${n}: wiki page has no : Canvas cannot match it`); + } + if (//