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.
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/CHANGELOG.md +41 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/PKG-INFO +46 -10
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/README.md +45 -9
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/pyproject.toml +1 -1
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/__init__.py +1 -1
- learningfoundry-0.37.0/src/learningfoundry/asset_resolver.py +220 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/cli.py +8 -12
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/generator.py +52 -1
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/pipeline.py +20 -12
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/resolver.py +37 -3
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/.gitignore +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/LICENSE +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/docs/project-guide/README.md +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/__main__.py +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/config.py +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/exceptions.py +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/integrations/__init__.py +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/integrations/d3foundry_stub.py +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/integrations/nbfoundry_stub.py +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/integrations/protocols.py +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/integrations/quizazz.py +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/logging_config.py +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/parser.py +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/py.typed +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/schema_v1.py +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/package.json +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/pnpm-lock.yaml +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/app.css +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/app.html +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonList.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/Navigation.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/db/database.ts +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/db/index.ts +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/db/progress.ts +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.test.ts +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/types/index.ts +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.test.ts +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.ts +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.ts +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/routes/+page.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/static/.gitkeep +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/svelte.config.js +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/tsconfig.json +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/vite.config.ts +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/app.css +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/app.html +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/LessonList.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/LessonView.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/ModuleList.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/Navigation.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/ProgressDashboard.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/TextBlock.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/db/database.ts +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/db/index.ts +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/db/progress.ts +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/types/index.ts +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/utils/markdown.ts +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/routes/+layout.svelte +0 -0
- {learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/routes/+page.svelte +0 -0
- {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 (``, ``) 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.
|
|
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
|
|
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`
|
|
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
|
+

|
|
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 (``, ``) 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
|
|
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`
|
|
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
|
+

|
|
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 (``, ``) 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.
|
|
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"
|
|
@@ -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 (````,
|
|
6
|
+
````, 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: `` or ``.
|
|
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''
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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``
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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,
|
|
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":
|
|
299
|
+
content={"markdown": rewritten, "path": str(content_path)},
|
|
266
300
|
)
|
|
267
301
|
|
|
268
302
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/integrations/__init__.py
RENAMED
|
File without changes
|
{learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/integrations/d3foundry_stub.py
RENAMED
|
File without changes
|
{learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/integrations/nbfoundry_stub.py
RENAMED
|
File without changes
|
{learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/integrations/protocols.py
RENAMED
|
File without changes
|
{learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/integrations/quizazz.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{learningfoundry-0.35.0 → learningfoundry-0.37.0}/src/learningfoundry/sveltekit_template/src/app.css
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/stores/curriculum.ts
RENAMED
|
File without changes
|
|
File without changes
|
{learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/lib/utils/markdown.ts
RENAMED
|
File without changes
|
{learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/routes/+layout.svelte
RENAMED
|
File without changes
|
{learningfoundry-0.35.0 → learningfoundry-0.37.0}/sveltekit_template/src/routes/+page.svelte
RENAMED
|
File without changes
|
|
File without changes
|