learningfoundry 0.35.0__tar.gz → 0.37.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/CHANGELOG.md +41 -0
  2. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/PKG-INFO +46 -10
  3. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/README.md +45 -9
  4. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/pyproject.toml +1 -1
  5. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/__init__.py +1 -1
  6. learningfoundry-0.37.0/src/learningfoundry/asset_resolver.py +220 -0
  7. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/cli.py +8 -12
  8. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/generator.py +52 -1
  9. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/pipeline.py +20 -12
  10. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/resolver.py +37 -3
  11. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/.gitignore +0 -0
  12. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/LICENSE +0 -0
  13. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/docs/project-guide/README.md +0 -0
  14. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/__main__.py +0 -0
  15. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/config.py +0 -0
  16. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/exceptions.py +0 -0
  17. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/integrations/__init__.py +0 -0
  18. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/integrations/d3foundry_stub.py +0 -0
  19. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/integrations/nbfoundry_stub.py +0 -0
  20. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/integrations/protocols.py +0 -0
  21. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/integrations/quizazz.py +0 -0
  22. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/logging_config.py +0 -0
  23. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/parser.py +0 -0
  24. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/py.typed +0 -0
  25. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/schema_v1.py +0 -0
  26. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/package.json +0 -0
  27. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/pnpm-lock.yaml +0 -0
  28. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/app.css +0 -0
  29. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/app.html +0 -0
  30. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
  31. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
  32. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonList.svelte +0 -0
  33. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte +0 -0
  34. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.svelte +0 -0
  35. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/Navigation.svelte +0 -0
  36. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
  37. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
  38. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte +0 -0
  39. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
  40. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.svelte +0 -0
  41. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -0
  42. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
  43. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/db/database.ts +0 -0
  44. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/db/index.ts +0 -0
  45. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/db/progress.ts +0 -0
  46. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.test.ts +0 -0
  47. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
  48. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/types/index.ts +0 -0
  49. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.test.ts +0 -0
  50. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.ts +0 -0
  51. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.svelte +0 -0
  52. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.ts +0 -0
  53. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/routes/+page.svelte +0 -0
  54. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +0 -0
  55. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/static/.gitkeep +0 -0
  56. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/svelte.config.js +0 -0
  57. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/tsconfig.json +0 -0
  58. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/vite.config.ts +0 -0
  59. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/app.css +0 -0
  60. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/app.html +0 -0
  61. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
  62. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
  63. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/LessonList.svelte +0 -0
  64. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/LessonView.svelte +0 -0
  65. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/ModuleList.svelte +0 -0
  66. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/Navigation.svelte +0 -0
  67. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
  68. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
  69. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/ProgressDashboard.svelte +0 -0
  70. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
  71. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/TextBlock.svelte +0 -0
  72. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -0
  73. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
  74. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/db/database.ts +0 -0
  75. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/db/index.ts +0 -0
  76. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/db/progress.ts +0 -0
  77. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
  78. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/types/index.ts +0 -0
  79. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/utils/markdown.ts +0 -0
  80. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/routes/+layout.svelte +0 -0
  81. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/routes/+page.svelte +0 -0
  82. {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +0 -0
@@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.37.0] - 2026-04-29
11
+
12
+ ### Added
13
+
14
+ - **First-class support for co-located image assets in lesson markdown.** Authors can now reference images directly from a lesson's markdown using either the markdown form (`![alt](path)`, `![alt](path "title")`) or the HTML form (`<img src="path">`); relative paths are resolved against the markdown file's own directory so authors keep images next to the markdown that uses them. `learningfoundry build` copies each unique image into `dist/static/content/<sha256[:12]>/<basename>` and rewrites the markdown URL to the absolute path `/content/<sha256[:12]>/<basename>` so it resolves at every nested route in the generated app. Same image referenced from N lessons → copied once (deduped by content hash). Absolute URLs (`https://`, `http://`, `//`, leading `/`, `data:` URIs) pass through unchanged so authors can mix CDN-hosted and co-located images. Image refs inside fenced code blocks (`` ``` `` or `~~~`) are left as literal text so code samples that demonstrate image syntax aren't silently rewritten. Missing images fail the build with the lesson location AND the expected on-disk path in the error message.
15
+ - New module `src/learningfoundry/asset_resolver.py` — pure function `resolve_markdown_assets(markdown, markdown_path) → (rewritten_markdown, list[Asset])`. Skips fenced code blocks, normalises query/fragment off the on-disk lookup, and dedupes by `dest_relative` (= content hash).
16
+ - `src/learningfoundry/resolver.py` — text-block resolution now invokes `resolve_markdown_assets()`; `ResolvedCurriculum` gained a top-level `assets: list[Asset]` field aggregated globally across modules/lessons (deduped by content hash).
17
+ - `src/learningfoundry/generator.py` — new `_copy_assets()` step copies each `Asset` into `output_dir/static/<dest_relative>` (idempotent on matching size, since the path is content-hashed). `_write_curriculum_json()` now strips `assets` from the serialised tree (the field carries on-disk `Path` objects and is consumed only by the generator).
18
+ - `_PRESERVED_PATHS` extended with `"static/content"` so previously-copied image assets survive a `learningfoundry build` re-run alongside `node_modules/`, `pnpm-lock.yaml`, `build/`, and `.svelte-kit/`.
19
+
20
+ ### Documentation
21
+
22
+ - `README.md` — new "Images and assets" section in the Table of Contents with a worked example, the rules for relative vs. absolute URLs, the dedup-by-hash behaviour, and a note on how `static/content/` flows through to `build/content/` for static-export deployment.
23
+ - `docs/specs/features.md` — Inputs section documents the image co-location convention; Outputs section documents the generated `static/content/<hash12>/<basename>` directory; FR-2 (Content Resolution) gained an "Image asset resolution" sub-requirement covering the regex strategy, passthrough rules, dedup, and error semantics.
24
+ - `docs/specs/tech-spec.md` — added `asset_resolver.py` to Package Structure; added a new "asset_resolver.py — Markdown Image Asset Resolution" Key Component Design section; updated the `resolver.py` and `generator.py` sections to describe the asset hand-off; documented `Asset` and the `assets` field in Data Models.
25
+
26
+ ### Added (tests)
27
+
28
+ - `tests/test_asset_resolver.py` — 19 cases covering relative-image resolution, subdirectory paths, title attributes, all five passthrough URL forms, missing-file error messages, dedup of identical content, hash separation of same-basename-different-bytes, HTML `<img>` (single + double quoted), fenced-code-block skipping for both `` ``` `` and `~~~`, query/fragment stripping, no-image no-op, and the `Asset.url_path` property.
29
+ - `tests/test_resolver.py::TestTextBlockImageAssets` — 4 cases asserting that `resolve_curriculum()` populates `ResolvedCurriculum.assets`, rewrites lesson markdown to `/content/...` URLs, surfaces missing-image errors with the lesson location, and dedupes assets across lessons.
30
+ - `tests/test_generator.py::TestImageAssetCopy` (4 cases) and `TestStaticContentPreserved` (2 cases) — verify that `Asset` records land on disk under `static/<dest_relative>`, that `assets` is stripped from `curriculum.json`, that absent assets don't create an empty `static/content/`, that rebuilds are idempotent on unchanged assets, and that an existing `static/content/` survives a rebuild.
31
+ - `tests/test_smoke_sveltekit.py::test_co_located_image_reaches_build_output` — added a co-located `diagram.png` to `tests/fixtures/content/mod-01/` (referenced from `lesson-01.md`) and asserts the image lands at `build/content/<hash12>/diagram.png` after the full `learningfoundry build → pnpm install → pnpm build` smoke pipeline.
32
+
33
+ ## [0.36.0] - 2026-04-29
34
+
35
+ ### Changed
36
+
37
+ - **`learningfoundry preview` is now the canonical "see your work" command.** Previously the post-build prompt and the README disagreed: the CLI told users to `cd dist && pnpm install && pnpm build` (a static export that exits without serving), while the README told them to run `learningfoundry preview`. Users following whichever doc they read first ended up with redundant work or wasted `pnpm install` invocations. The CLI's post-build prompt now consistently points at `learningfoundry preview` for every `DepState`, with `cd dist && pnpm build` mentioned only as the "for a static export to deploy" alternative.
38
+ - `src/learningfoundry/cli.py` — collapsed the three-branch `DepState` prompt into a single message: `Next: learningfoundry preview` (with a `⚠️ Dependencies changed …` line prepended in the `CHANGED` case so the user knows the upcoming `learningfoundry preview` will reinstall).
39
+ - `README.md` — Quick Start step 3 now combines build+preview into one `learningfoundry preview` invocation; the `learningfoundry preview` reference section explicitly notes that it serves the SvelteKit project from source via Vite (not the `pnpm build` static output) and now skips `pnpm install` when nothing has changed.
40
+
41
+ ### Performance
42
+
43
+ - **`learningfoundry preview` no longer runs `pnpm install` on every invocation.** It now consults `check_dep_state(output_dir)` and skips the install step entirely when the state is `UNCHANGED` (every declared dep is already present in `node_modules/`). Subsequent `learningfoundry preview` runs after a content edit go straight to `pnpm run dev`, saving 5–30 s per cycle. The install still runs unconditionally on `FIRST_BUILD` and `CHANGED` states.
44
+ - `src/learningfoundry/pipeline.py::run_preview` — imports `DepState` and `check_dep_state`; logs `Dependencies up to date — skipping pnpm install.` when the state is `UNCHANGED`.
45
+
46
+ ### Added (tests)
47
+
48
+ - `tests/test_cli.py::TestBuildNextStepsPrompt` — 3 cases asserting the new build prompt wording for `FIRST_BUILD`, `UNCHANGED`, and `CHANGED` states (each must say `Next: learningfoundry preview`; only `CHANGED` mentions the dep-change warning).
49
+ - `tests/test_pipeline.py::TestRunPreviewSkipsInstall` — 3 cases verifying that `run_preview` invokes `pnpm install` on `FIRST_BUILD` and `CHANGED` but not on `UNCHANGED`, while always invoking `pnpm run dev`.
50
+
10
51
  ## [0.35.0] - 2026-04-29
11
52
 
12
53
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learningfoundry
3
- Version: 0.35.0
3
+ Version: 0.37.0
4
4
  Summary: A curriculum engine that turns a YAML curriculum definition into a deployable SvelteKit learning application.
5
5
  Project-URL: Homepage, https://github.com/pointmatic/learningfoundry
6
6
  Project-URL: Repository, https://github.com/pointmatic/learningfoundry
@@ -45,6 +45,7 @@ A curriculum engine that turns a YAML curriculum definition into a deployable Sv
45
45
  - [Quick Start](#quick-start)
46
46
  - [CLI Reference](#cli-reference)
47
47
  - [Curriculum YAML Format](#curriculum-yaml-format)
48
+ - [Images and assets](#images-and-assets)
48
49
  - [Configuration File](#configuration-file)
49
50
  - [Development Setup](#development-setup)
50
51
 
@@ -115,20 +116,17 @@ pip install "learningfoundry[quizazz]"
115
116
  # OK — curriculum is valid.
116
117
  ```
117
118
 
118
- 3. **Build** the SvelteKit app:
119
-
120
- ```bash
121
- learningfoundry build
122
- # Build complete → dist/
123
- ```
124
-
125
- 4. **Preview** locally (builds then starts a dev server):
119
+ 3. **Build and preview** locally:
126
120
 
127
121
  ```bash
128
122
  learningfoundry preview
129
123
  # Preview server started at http://localhost:5173
130
124
  ```
131
125
 
126
+ `learningfoundry preview` is the canonical "see your work" command — it builds the SvelteKit project, installs Node dependencies on first run (and again whenever they change), and starts a Vite dev server. On subsequent runs it skips the install step automatically.
127
+
128
+ `learningfoundry build` alone is also available if you want to generate the SvelteKit project without serving it (e.g. to inspect output, deploy a static export via `cd dist && pnpm build`, or wire into your own toolchain).
129
+
132
130
  ---
133
131
 
134
132
  ## CLI Reference
@@ -198,7 +196,9 @@ Options:
198
196
  --help Show this message and exit.
199
197
  ```
200
198
 
201
- Runs `pnpm install` and `pnpm run dev` in the generated project directory. Requires `pnpm` on `PATH`.
199
+ Runs `learningfoundry build`, then `pnpm install` (skipped when every declared dependency is already present in `node_modules/`), then `pnpm run dev` in the generated project directory. Requires `pnpm` on `PATH`.
200
+
201
+ This serves the SvelteKit project from source via Vite's dev server; it does **not** serve the static `pnpm build` output in `dist/build/`. For static deploys, use `cd dist && pnpm build` and host the resulting `dist/build/` directory on any static host.
202
202
 
203
203
  ---
204
204
 
@@ -264,6 +264,42 @@ curriculum:
264
264
 
265
265
  ---
266
266
 
267
+ ## Images and assets
268
+
269
+ Lesson markdown can embed images directly. Place the image file alongside the markdown that uses it and reference it with a relative path:
270
+
271
+ ```
272
+ content/
273
+ └── mod-01/
274
+ ├── lesson-01.md
275
+ ├── diagram.png
276
+ └── figures/
277
+ └── architecture.svg
278
+ ```
279
+
280
+ ```markdown
281
+ # Lesson One
282
+
283
+ ![Architecture diagram](figures/architecture.svg "Hover title")
284
+
285
+ Here is a smaller inline diagram:
286
+
287
+ <img src="diagram.png" alt="Diagram" />
288
+ ```
289
+
290
+ **How it works:**
291
+
292
+ - Relative URLs (`diagram.png`, `figures/architecture.svg`) are resolved against the markdown file's own directory. `learningfoundry build` copies each unique image into `dist/static/content/<sha256[:12]>/<basename>` and rewrites the markdown URL to the absolute path `/content/<sha256[:12]>/<basename>` so it resolves at every nested route in the generated app.
293
+ - Both the markdown form (`![alt](path)`, `![alt](path "title")`) and the HTML form (`<img src="path">`) are recognised.
294
+ - Absolute URLs (`https://`, `http://`, protocol-relative `//...`, root-absolute `/...`) and `data:` URIs pass through unchanged — useful for CDN-hosted assets you don't want copied into the build.
295
+ - Image references inside fenced code blocks (` ``` ` or `~~~`) are left as literal text, so code samples that *demonstrate* image syntax aren't silently rewritten.
296
+ - The same image referenced from N lessons is copied exactly once (deduped by content hash).
297
+ - A missing image fails the build with the lesson location and the expected on-disk path in the error message.
298
+
299
+ For production deployment to a CDN, just run `cd dist && pnpm build` — the `static/content/` tree gets bundled into the static export under `build/content/`, so deploying `build/` to any static host (Cloudflare Pages, Netlify, S3+CloudFront, …) serves the images at the same URLs the markdown references.
300
+
301
+ ---
302
+
267
303
  ## Configuration File
268
304
 
269
305
  An optional config file can set defaults for logging. The CLI always takes precedence.
@@ -16,6 +16,7 @@ A curriculum engine that turns a YAML curriculum definition into a deployable Sv
16
16
  - [Quick Start](#quick-start)
17
17
  - [CLI Reference](#cli-reference)
18
18
  - [Curriculum YAML Format](#curriculum-yaml-format)
19
+ - [Images and assets](#images-and-assets)
19
20
  - [Configuration File](#configuration-file)
20
21
  - [Development Setup](#development-setup)
21
22
 
@@ -86,20 +87,17 @@ pip install "learningfoundry[quizazz]"
86
87
  # OK — curriculum is valid.
87
88
  ```
88
89
 
89
- 3. **Build** the SvelteKit app:
90
-
91
- ```bash
92
- learningfoundry build
93
- # Build complete → dist/
94
- ```
95
-
96
- 4. **Preview** locally (builds then starts a dev server):
90
+ 3. **Build and preview** locally:
97
91
 
98
92
  ```bash
99
93
  learningfoundry preview
100
94
  # Preview server started at http://localhost:5173
101
95
  ```
102
96
 
97
+ `learningfoundry preview` is the canonical "see your work" command — it builds the SvelteKit project, installs Node dependencies on first run (and again whenever they change), and starts a Vite dev server. On subsequent runs it skips the install step automatically.
98
+
99
+ `learningfoundry build` alone is also available if you want to generate the SvelteKit project without serving it (e.g. to inspect output, deploy a static export via `cd dist && pnpm build`, or wire into your own toolchain).
100
+
103
101
  ---
104
102
 
105
103
  ## CLI Reference
@@ -169,7 +167,9 @@ Options:
169
167
  --help Show this message and exit.
170
168
  ```
171
169
 
172
- Runs `pnpm install` and `pnpm run dev` in the generated project directory. Requires `pnpm` on `PATH`.
170
+ Runs `learningfoundry build`, then `pnpm install` (skipped when every declared dependency is already present in `node_modules/`), then `pnpm run dev` in the generated project directory. Requires `pnpm` on `PATH`.
171
+
172
+ This serves the SvelteKit project from source via Vite's dev server; it does **not** serve the static `pnpm build` output in `dist/build/`. For static deploys, use `cd dist && pnpm build` and host the resulting `dist/build/` directory on any static host.
173
173
 
174
174
  ---
175
175
 
@@ -235,6 +235,42 @@ curriculum:
235
235
 
236
236
  ---
237
237
 
238
+ ## Images and assets
239
+
240
+ Lesson markdown can embed images directly. Place the image file alongside the markdown that uses it and reference it with a relative path:
241
+
242
+ ```
243
+ content/
244
+ └── mod-01/
245
+ ├── lesson-01.md
246
+ ├── diagram.png
247
+ └── figures/
248
+ └── architecture.svg
249
+ ```
250
+
251
+ ```markdown
252
+ # Lesson One
253
+
254
+ ![Architecture diagram](figures/architecture.svg "Hover title")
255
+
256
+ Here is a smaller inline diagram:
257
+
258
+ <img src="diagram.png" alt="Diagram" />
259
+ ```
260
+
261
+ **How it works:**
262
+
263
+ - Relative URLs (`diagram.png`, `figures/architecture.svg`) are resolved against the markdown file's own directory. `learningfoundry build` copies each unique image into `dist/static/content/<sha256[:12]>/<basename>` and rewrites the markdown URL to the absolute path `/content/<sha256[:12]>/<basename>` so it resolves at every nested route in the generated app.
264
+ - Both the markdown form (`![alt](path)`, `![alt](path "title")`) and the HTML form (`<img src="path">`) are recognised.
265
+ - Absolute URLs (`https://`, `http://`, protocol-relative `//...`, root-absolute `/...`) and `data:` URIs pass through unchanged — useful for CDN-hosted assets you don't want copied into the build.
266
+ - Image references inside fenced code blocks (` ``` ` or `~~~`) are left as literal text, so code samples that *demonstrate* image syntax aren't silently rewritten.
267
+ - The same image referenced from N lessons is copied exactly once (deduped by content hash).
268
+ - A missing image fails the build with the lesson location and the expected on-disk path in the error message.
269
+
270
+ For production deployment to a CDN, just run `cd dist && pnpm build` — the `static/content/` tree gets bundled into the static export under `build/content/`, so deploying `build/` to any static host (Cloudflare Pages, Netlify, S3+CloudFront, …) serves the images at the same URLs the markdown references.
271
+
272
+ ---
273
+
238
274
  ## Configuration File
239
275
 
240
276
  An optional config file can set defaults for logging. The CLI always takes precedence.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "learningfoundry"
7
- version = "0.35.0"
7
+ version = "0.37.0"
8
8
  description = "A curriculum engine that turns a YAML curriculum definition into a deployable SvelteKit learning application."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -1,4 +1,4 @@
1
1
  # Copyright 2026 Pointmatic
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
 
4
- __version__ = "0.35.0"
4
+ __version__ = "0.37.0"
@@ -0,0 +1,220 @@
1
+ # Copyright 2026 Pointmatic
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Markdown image asset resolution.
4
+
5
+ Scans a lesson's markdown source for image references (``![alt](path)``,
6
+ ``![alt](path "title")``, and the HTML ``<img src="path">`` form), copies
7
+ the referenced files into the generated SvelteKit project's ``static/``
8
+ directory under a content-keyed location (``content/<sha256[:12]>/<basename>``),
9
+ and rewrites the markdown URLs to absolute paths that resolve at any
10
+ SvelteKit route.
11
+
12
+ Absolute URLs (``http://``, ``https://``, protocol-relative ``//``, root-
13
+ absolute ``/...``) and ``data:`` URIs pass through unchanged so authors can
14
+ mix CDN-hosted images with co-located ones.
15
+
16
+ Fenced code blocks (``` ``` ``` ``` or ``~~~``) are skipped so literal
17
+ ``![](...)`` strings inside code samples are not treated as image refs.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import logging
24
+ import re
25
+ from collections.abc import Callable
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ from learningfoundry.exceptions import ContentResolutionError
30
+
31
+ logger = logging.getLogger("learningfoundry.asset_resolver")
32
+
33
+ # Markdown image: `![alt](url)` or `![alt](url "title")`.
34
+ # - Group 1: alt text (kept verbatim in the rewrite)
35
+ # - Group 2: URL (the bit we resolve and possibly rewrite)
36
+ # - Group 3: optional title (kept verbatim, including its surrounding quotes)
37
+ # We deliberately do not try to handle escaped parens inside the URL —
38
+ # CommonMark's full grammar is out of scope for v0.37.0; the common case
39
+ # is a plain relative path or an absolute URL with no whitespace.
40
+ _MD_IMAGE_RE = re.compile(
41
+ r"""
42
+ !\[(?P<alt>[^\]]*)\] # ![alt]
43
+ \( # opening paren
44
+ \s*
45
+ (?P<url>[^\s)]+) # url (no whitespace, no closing paren)
46
+ (?:\s+(?P<title>"[^"]*"|'[^']*'))? # optional "title" or 'title'
47
+ \s*
48
+ \) # closing paren
49
+ """,
50
+ re.VERBOSE,
51
+ )
52
+
53
+ # HTML <img src="..."> in either single- or double-quoted form.
54
+ # We reuse the same `url` group name so the substitution callback is uniform.
55
+ _HTML_IMG_RE = re.compile(
56
+ r"""
57
+ (?P<prefix><img\b[^>]*?\bsrc\s*=\s*)
58
+ (?P<quote>["'])
59
+ (?P<url>[^"']+)
60
+ (?P=quote)
61
+ """,
62
+ re.VERBOSE | re.IGNORECASE,
63
+ )
64
+
65
+ # A fenced code block opens with ≥3 backticks or ≥3 tildes at the start of a
66
+ # line (allowing up to 3 leading spaces per CommonMark) and closes with the
67
+ # same fence character at the same length-or-greater. We track the active
68
+ # fence character to avoid closing a backtick fence with a tilde line.
69
+ _FENCE_RE = re.compile(r"^ {0,3}(?P<fence>`{3,}|~{3,})")
70
+
71
+ # URL schemes / leading characters that mean "leave this alone — it is not
72
+ # a relative on-disk reference". Order matters only for clarity.
73
+ _PASSTHROUGH_PREFIXES: tuple[str, ...] = (
74
+ "http://",
75
+ "https://",
76
+ "//", # protocol-relative
77
+ "/", # already root-absolute (already a SvelteKit static path)
78
+ "data:", # inline data URI
79
+ "mailto:",
80
+ "tel:",
81
+ )
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class Asset:
86
+ """A single asset that must be copied into the generated project.
87
+
88
+ Attributes:
89
+ source: Absolute path to the source file on disk.
90
+ dest_relative: Destination path relative to the generated project's
91
+ ``static/`` directory (e.g. ``"content/abc123def456/diagram.png"``).
92
+ Always uses forward slashes — this becomes a URL fragment too.
93
+ """
94
+
95
+ source: Path
96
+ dest_relative: str
97
+
98
+ @property
99
+ def url_path(self) -> str:
100
+ """Absolute URL path the rewritten markdown references."""
101
+ return "/" + self.dest_relative
102
+
103
+
104
+ def resolve_markdown_assets(
105
+ markdown: str,
106
+ markdown_path: Path,
107
+ ) -> tuple[str, list[Asset]]:
108
+ """Find every relative image reference in ``markdown`` and resolve it.
109
+
110
+ Args:
111
+ markdown: Raw markdown source.
112
+ markdown_path: Filesystem path of the markdown file. Relative image
113
+ URLs are resolved against ``markdown_path.parent``.
114
+
115
+ Returns:
116
+ Tuple of ``(rewritten_markdown, assets)``. ``rewritten_markdown`` has
117
+ every relative image URL replaced with an absolute
118
+ ``/content/<hash>/<basename>`` path. ``assets`` is the deduped list of
119
+ source files to copy into the generated project (keyed on the content
120
+ hash, so two refs to the same bytes produce one ``Asset``).
121
+
122
+ Raises:
123
+ ContentResolutionError: A relative image reference points at a file
124
+ that does not exist, including the markdown file path in the
125
+ error message.
126
+ """
127
+ md_dir = markdown_path.parent
128
+ assets_by_dest: dict[str, Asset] = {}
129
+
130
+ def _make_asset(rel_url: str) -> Asset | None:
131
+ """Resolve ``rel_url`` to an Asset, or None if it should pass through.
132
+
133
+ Raises:
134
+ ContentResolutionError: The path resolves but the file is missing.
135
+ """
136
+ if _is_passthrough(rel_url):
137
+ return None
138
+ # Strip any fragment / query — image URLs rarely have them, but a
139
+ # `?cache=1` suffix should not become part of the on-disk lookup.
140
+ clean = rel_url.split("#", 1)[0].split("?", 1)[0]
141
+ if not clean:
142
+ return None
143
+ source = (md_dir / clean).resolve()
144
+ if not source.is_file():
145
+ raise ContentResolutionError(
146
+ f"image asset not found: `{clean}` "
147
+ f"(resolved to `{source}`, referenced from `{markdown_path}`)"
148
+ )
149
+ try:
150
+ digest = hashlib.sha256(source.read_bytes()).hexdigest()[:12]
151
+ except OSError as exc:
152
+ raise ContentResolutionError(
153
+ f"failed to read image asset `{source}` "
154
+ f"(referenced from `{markdown_path}`): {exc}"
155
+ ) from exc
156
+ dest_relative = f"content/{digest}/{source.name}"
157
+ asset = assets_by_dest.get(dest_relative)
158
+ if asset is None:
159
+ asset = Asset(source=source, dest_relative=dest_relative)
160
+ assets_by_dest[dest_relative] = asset
161
+ return asset
162
+
163
+ rewritten_lines: list[str] = []
164
+ in_fence = False
165
+ fence_char: str | None = None
166
+
167
+ for line in markdown.splitlines(keepends=True):
168
+ match = _FENCE_RE.match(line)
169
+ if match:
170
+ fence = match.group("fence")
171
+ if not in_fence:
172
+ in_fence = True
173
+ fence_char = fence[0]
174
+ elif fence[0] == fence_char and len(fence) >= 3:
175
+ in_fence = False
176
+ fence_char = None
177
+ rewritten_lines.append(line)
178
+ continue
179
+
180
+ if in_fence:
181
+ rewritten_lines.append(line)
182
+ continue
183
+
184
+ rewritten_lines.append(_rewrite_line(line, _make_asset))
185
+
186
+ rewritten = "".join(rewritten_lines)
187
+ return rewritten, list(assets_by_dest.values())
188
+
189
+
190
+ def _is_passthrough(url: str) -> bool:
191
+ """Return True if ``url`` should not be treated as an on-disk reference."""
192
+ return any(url.startswith(p) for p in _PASSTHROUGH_PREFIXES)
193
+
194
+
195
+ def _rewrite_line(
196
+ line: str,
197
+ make_asset: Callable[[str], Asset | None],
198
+ ) -> str:
199
+ """Rewrite all image refs in a single non-fenced line."""
200
+
201
+ def _md_sub(m: re.Match[str]) -> str:
202
+ url = m.group("url")
203
+ asset = make_asset(url)
204
+ if asset is None:
205
+ return m.group(0)
206
+ title = m.group("title")
207
+ title_part = f' {title}' if title else ''
208
+ return f'![{m.group("alt")}]({asset.url_path}{title_part})'
209
+
210
+ def _html_sub(m: re.Match[str]) -> str:
211
+ url = m.group("url")
212
+ asset = make_asset(url)
213
+ if asset is None:
214
+ return m.group(0)
215
+ quote = m.group("quote")
216
+ return f'{m.group("prefix")}{quote}{asset.url_path}{quote}'
217
+
218
+ line = _MD_IMAGE_RE.sub(_md_sub, line)
219
+ line = _HTML_IMG_RE.sub(_html_sub, line)
220
+ return line
@@ -113,21 +113,17 @@ def build(
113
113
  from learningfoundry.generator import DepState, check_dep_state
114
114
 
115
115
  state = check_dep_state(output_dir)
116
- if state is DepState.FIRST_BUILD:
117
- click.echo("")
118
- click.echo(f"Next: cd {output_dir} && pnpm install && pnpm build")
119
- click.echo(" (or `pnpm dev` for a live-reloading dev server)")
120
- elif state is DepState.CHANGED:
121
- click.echo("")
116
+ click.echo("")
117
+ if state is DepState.CHANGED:
122
118
  click.echo(
123
119
  "⚠️ Dependencies changed since last install "
124
- "(new packages in package.json)."
120
+ "(new packages in package.json) — `learningfoundry preview` "
121
+ "will reinstall."
125
122
  )
126
- click.echo(f" Run: cd {output_dir} && pnpm install && pnpm build")
127
- else:
128
- click.echo("")
129
- click.echo(f"Next: cd {output_dir} && pnpm build")
130
- click.echo(" (or `pnpm dev` for a live-reloading dev server)")
123
+ click.echo("Next: learningfoundry preview")
124
+ click.echo(
125
+ f" (or `cd {output_dir} && pnpm build` for a static export to deploy)"
126
+ )
131
127
 
132
128
 
133
129
  # ---------------------------------------------------------------------------
@@ -21,11 +21,17 @@ _TEMPLATE_DIR = Path(__file__).parent / "sveltekit_template"
21
21
  # are preserved across `learningfoundry build` re-runs so the user does not
22
22
  # have to `pnpm install` after every regen. Template files (package.json,
23
23
  # src/, static/, configs) are still refreshed every time.
24
+ #
25
+ # `static/content` is preserved so previously-copied image assets
26
+ # (content-hashed under `static/content/<hash12>/<basename>`) survive a
27
+ # rebuild — keeps `learningfoundry preview` snappy when only markdown
28
+ # text changed and no new images were introduced.
24
29
  _PRESERVED_PATHS: tuple[str, ...] = (
25
30
  "node_modules",
26
31
  "pnpm-lock.yaml",
27
32
  "build",
28
33
  ".svelte-kit",
34
+ "static/content",
29
35
  )
30
36
 
31
37
 
@@ -83,6 +89,7 @@ def generate_app(
83
89
  )
84
90
 
85
91
  _atomic_copy(src, output_dir)
92
+ _copy_assets(resolved, output_dir)
86
93
  _write_curriculum_json(resolved, output_dir)
87
94
 
88
95
  logger.info("Generated SvelteKit project at: %s", output_dir)
@@ -174,13 +181,19 @@ def check_dep_state(output_dir: Path) -> DepState:
174
181
 
175
182
 
176
183
  def _write_curriculum_json(resolved: ResolvedCurriculum, output_dir: Path) -> None:
177
- """Serialize ResolvedCurriculum to output_dir/static/curriculum.json."""
184
+ """Serialize ResolvedCurriculum to output_dir/static/curriculum.json.
185
+
186
+ The ``assets`` list is intentionally stripped — it carries on-disk
187
+ ``Path`` objects (which are not JSON-serialisable) and is consumed only
188
+ by the generator's asset-copy step, never by the SvelteKit frontend.
189
+ """
178
190
  static_dir = output_dir / "static"
179
191
  static_dir.mkdir(parents=True, exist_ok=True)
180
192
 
181
193
  curriculum_json = output_dir / "static" / "curriculum.json"
182
194
  try:
183
195
  data = dataclasses.asdict(resolved)
196
+ data.pop("assets", None)
184
197
  curriculum_json.write_text(
185
198
  json.dumps(data, indent=2, ensure_ascii=False) + "\n",
186
199
  encoding="utf-8",
@@ -191,3 +204,41 @@ def _write_curriculum_json(resolved: ResolvedCurriculum, output_dir: Path) -> No
191
204
  ) from exc
192
205
 
193
206
  logger.debug("Wrote curriculum.json (%d bytes)", curriculum_json.stat().st_size)
207
+
208
+
209
+ def _copy_assets(resolved: ResolvedCurriculum, output_dir: Path) -> None:
210
+ """Copy each ``Asset`` to ``output_dir/static/<dest_relative>``.
211
+
212
+ Idempotent: a destination file whose size matches the source is left
213
+ untouched. Because ``dest_relative`` is content-hashed
214
+ (``content/<sha256[:12]>/<basename>``), a matching size on a path with a
215
+ matching hash implies matching content — re-copying would be wasted I/O.
216
+ """
217
+ if not resolved.assets:
218
+ return
219
+
220
+ static_dir = output_dir / "static"
221
+ static_dir.mkdir(parents=True, exist_ok=True)
222
+
223
+ copied = 0
224
+ skipped = 0
225
+ for asset in resolved.assets:
226
+ dest = static_dir / asset.dest_relative
227
+ try:
228
+ if dest.is_file() and dest.stat().st_size == asset.source.stat().st_size:
229
+ skipped += 1
230
+ continue
231
+ dest.parent.mkdir(parents=True, exist_ok=True)
232
+ shutil.copy2(asset.source, dest)
233
+ copied += 1
234
+ except OSError as exc:
235
+ raise GenerationError(
236
+ f"Failed to copy image asset `{asset.source}` "
237
+ f"to `{dest}`: {exc}"
238
+ ) from exc
239
+
240
+ logger.info(
241
+ "Copied %d image asset(s) into static/ (%d already up to date).",
242
+ copied,
243
+ skipped,
244
+ )
@@ -133,8 +133,11 @@ def run_preview(
133
133
  ) -> None:
134
134
  """Build then launch a local preview server.
135
135
 
136
- Runs ``run_build()``, then ``pnpm install`` and ``pnpm run dev --port``
137
- in the generated project directory.
136
+ Runs ``run_build()``, then ``pnpm install`` (only when needed) and
137
+ ``pnpm run dev --port`` in the generated project directory. The install
138
+ step is skipped when ``check_dep_state(output_dir)`` reports
139
+ ``DepState.UNCHANGED`` — i.e. every dependency declared in the
140
+ generated ``package.json`` is already present in ``node_modules/``.
138
141
 
139
142
  Args:
140
143
  curriculum_path: Path to the curriculum YAML file.
@@ -150,6 +153,7 @@ def run_preview(
150
153
  GenerationError: If build or pnpm commands fail.
151
154
  """
152
155
  from learningfoundry.exceptions import GenerationError
156
+ from learningfoundry.generator import DepState, check_dep_state
153
157
 
154
158
  run_build(
155
159
  curriculum_path,
@@ -161,17 +165,21 @@ def run_preview(
161
165
  generator=generator,
162
166
  )
163
167
 
164
- logger.info("Installing Node dependencies in %s", output_dir)
165
- result = subprocess.run(
166
- ["pnpm", "install"],
167
- cwd=output_dir,
168
- capture_output=True,
169
- text=True,
170
- )
171
- if result.returncode != 0:
172
- raise GenerationError(
173
- f"`pnpm install` failed in `{output_dir}`:\n{result.stderr}"
168
+ state = check_dep_state(output_dir)
169
+ if state is DepState.UNCHANGED:
170
+ logger.info("Dependencies up to date — skipping pnpm install.")
171
+ else:
172
+ logger.info("Installing Node dependencies in %s", output_dir)
173
+ result = subprocess.run(
174
+ ["pnpm", "install"],
175
+ cwd=output_dir,
176
+ capture_output=True,
177
+ text=True,
174
178
  )
179
+ if result.returncode != 0:
180
+ raise GenerationError(
181
+ f"`pnpm install` failed in `{output_dir}`:\n{result.stderr}"
182
+ )
175
183
 
176
184
  logger.info("Starting dev server on port %d", port)
177
185
  subprocess.run(
@@ -8,6 +8,7 @@ from dataclasses import dataclass, field
8
8
  from pathlib import Path
9
9
  from typing import Any
10
10
 
11
+ from learningfoundry.asset_resolver import Asset, resolve_markdown_assets
11
12
  from learningfoundry.exceptions import ContentResolutionError
12
13
  from learningfoundry.integrations.protocols import (
13
14
  ExerciseProvider,
@@ -63,6 +64,11 @@ class ResolvedCurriculum:
63
64
  title: str
64
65
  description: str
65
66
  modules: list[ResolvedModule] = field(default_factory=list)
67
+ # Image assets referenced from any text block's markdown, deduped by
68
+ # content hash. Carried out-of-band — the generator copies these into
69
+ # ``static/`` and they are stripped before curriculum.json is written
70
+ # (the SvelteKit frontend never sees them).
71
+ assets: list[Asset] = field(default_factory=list)
66
72
 
67
73
 
68
74
  def resolve_curriculum(
@@ -105,6 +111,10 @@ def resolve_curriculum(
105
111
  visualization_provider = D3foundryStub()
106
112
 
107
113
  resolved_modules: list[ResolvedModule] = []
114
+ # Image assets are deduped globally on `dest_relative` (which is keyed
115
+ # on the content hash), so a single image referenced from N lessons is
116
+ # copied exactly once into the generated project.
117
+ assets_by_dest: dict[str, Asset] = {}
108
118
  for module in curriculum.curriculum.modules:
109
119
  resolved_modules.append(
110
120
  _resolve_module(
@@ -113,6 +123,7 @@ def resolve_curriculum(
113
123
  quiz_provider,
114
124
  exercise_provider,
115
125
  visualization_provider,
126
+ assets_by_dest,
116
127
  )
117
128
  )
118
129
 
@@ -121,6 +132,7 @@ def resolve_curriculum(
121
132
  title=curriculum.curriculum.title,
122
133
  description=curriculum.curriculum.description,
123
134
  modules=resolved_modules,
135
+ assets=list(assets_by_dest.values()),
124
136
  )
125
137
 
126
138
 
@@ -130,6 +142,7 @@ def _resolve_module(
130
142
  quiz_provider: QuizProvider,
131
143
  exercise_provider: ExerciseProvider,
132
144
  visualization_provider: VisualizationProvider,
145
+ assets_by_dest: dict[str, Asset],
133
146
  ) -> ResolvedModule:
134
147
  pre = None
135
148
  post = None
@@ -158,6 +171,7 @@ def _resolve_module(
158
171
  quiz_provider,
159
172
  exercise_provider,
160
173
  visualization_provider,
174
+ assets_by_dest,
161
175
  )
162
176
  )
163
177
 
@@ -178,6 +192,7 @@ def _resolve_lesson(
178
192
  quiz_provider: QuizProvider,
179
193
  exercise_provider: ExerciseProvider,
180
194
  visualization_provider: VisualizationProvider,
195
+ assets_by_dest: dict[str, Asset],
181
196
  ) -> ResolvedLesson:
182
197
  resolved_blocks: list[ResolvedContentBlock] = []
183
198
  for idx, block in enumerate(lesson.content_blocks):
@@ -190,6 +205,7 @@ def _resolve_lesson(
190
205
  exercise_provider,
191
206
  visualization_provider,
192
207
  location,
208
+ assets_by_dest,
193
209
  )
194
210
  )
195
211
  return ResolvedLesson(
@@ -206,10 +222,11 @@ def _resolve_block(
206
222
  exercise_provider: ExerciseProvider,
207
223
  visualization_provider: VisualizationProvider,
208
224
  location: str,
225
+ assets_by_dest: dict[str, Asset],
209
226
  ) -> ResolvedContentBlock:
210
227
  try:
211
228
  if isinstance(block, TextBlock):
212
- return _resolve_text(block, base_dir, location)
229
+ return _resolve_text(block, base_dir, location, assets_by_dest)
213
230
  if isinstance(block, VideoBlock):
214
231
  return _resolve_video(block, location)
215
232
  if isinstance(block, QuizBlock):
@@ -245,7 +262,10 @@ def _resolve_block(
245
262
 
246
263
 
247
264
  def _resolve_text(
248
- block: TextBlock, base_dir: Path, location: str
265
+ block: TextBlock,
266
+ base_dir: Path,
267
+ location: str,
268
+ assets_by_dest: dict[str, Asset],
249
269
  ) -> ResolvedContentBlock:
250
270
  content_path = base_dir / block.ref
251
271
  try:
@@ -258,11 +278,25 @@ def _resolve_text(
258
278
  if not text.strip():
259
279
  logger.warning("%s: markdown file `%s` is empty.", location, content_path)
260
280
 
281
+ # Scan for image refs, copy-relative on disk, rewrite to absolute
282
+ # `/content/<hash>/<basename>` URLs that work at any SvelteKit route.
283
+ # Missing images surface as ContentResolutionError tagged with the
284
+ # block location for parity with other resolution errors.
285
+ try:
286
+ rewritten, lesson_assets = resolve_markdown_assets(text, content_path)
287
+ except ContentResolutionError as exc:
288
+ raise ContentResolutionError(f"{location}: {exc}") from exc
289
+
290
+ for asset in lesson_assets:
291
+ # Dedup globally — two lessons referencing the same image hash to
292
+ # the same dest_relative, so the dict swallows the duplicate.
293
+ assets_by_dest.setdefault(asset.dest_relative, asset)
294
+
261
295
  return ResolvedContentBlock(
262
296
  type="text",
263
297
  source=None,
264
298
  ref=block.ref,
265
- content={"markdown": text, "path": str(content_path)},
299
+ content={"markdown": rewritten, "path": str(content_path)},
266
300
  )
267
301
 
268
302