learningfoundry 0.32.0__tar.gz → 0.34.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.32.0 → learningfoundry-0.34.0}/CHANGELOG.md +39 -0
  2. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/PKG-INFO +1 -1
  3. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/pyproject.toml +1 -1
  4. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/__init__.py +1 -1
  5. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/cli.py +19 -0
  6. learningfoundry-0.34.0/src/learningfoundry/generator.py +193 -0
  7. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/package.json +2 -0
  8. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/app.css +1 -0
  9. learningfoundry-0.34.0/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.test.ts +54 -0
  10. {learningfoundry-0.32.0 → learningfoundry-0.34.0/src/learningfoundry}/sveltekit_template/src/lib/utils/markdown.ts +6 -0
  11. learningfoundry-0.32.0/src/learningfoundry/generator.py +0 -99
  12. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/.gitignore +0 -0
  13. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/LICENSE +0 -0
  14. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/README.md +0 -0
  15. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/docs/project-guide/README.md +0 -0
  16. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/__main__.py +0 -0
  17. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/config.py +0 -0
  18. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/exceptions.py +0 -0
  19. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/integrations/__init__.py +0 -0
  20. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/integrations/d3foundry_stub.py +0 -0
  21. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/integrations/nbfoundry_stub.py +0 -0
  22. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/integrations/protocols.py +0 -0
  23. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/integrations/quizazz.py +0 -0
  24. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/logging_config.py +0 -0
  25. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/parser.py +0 -0
  26. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/pipeline.py +0 -0
  27. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/py.typed +0 -0
  28. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/resolver.py +0 -0
  29. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/schema_v1.py +0 -0
  30. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/pnpm-lock.yaml +0 -0
  31. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/app.html +0 -0
  32. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
  33. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
  34. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonList.svelte +0 -0
  35. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte +0 -0
  36. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.svelte +0 -0
  37. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/lib/components/Navigation.svelte +0 -0
  38. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
  39. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
  40. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte +0 -0
  41. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
  42. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.svelte +0 -0
  43. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -0
  44. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
  45. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/lib/db/database.ts +0 -0
  46. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/lib/db/index.ts +0 -0
  47. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/lib/db/progress.ts +0 -0
  48. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.test.ts +0 -0
  49. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
  50. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/lib/types/index.ts +0 -0
  51. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.svelte +0 -0
  52. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/routes/+layout.ts +0 -0
  53. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/routes/+page.svelte +0 -0
  54. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +0 -0
  55. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/static/.gitkeep +0 -0
  56. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/svelte.config.js +0 -0
  57. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/tsconfig.json +0 -0
  58. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/src/learningfoundry/sveltekit_template/vite.config.ts +0 -0
  59. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/app.css +0 -0
  60. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/app.html +0 -0
  61. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
  62. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
  63. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/lib/components/LessonList.svelte +0 -0
  64. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/lib/components/LessonView.svelte +0 -0
  65. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/lib/components/ModuleList.svelte +0 -0
  66. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/lib/components/Navigation.svelte +0 -0
  67. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
  68. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
  69. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/lib/components/ProgressDashboard.svelte +0 -0
  70. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/lib/components/QuizBlock.svelte +0 -0
  71. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/lib/components/TextBlock.svelte +0 -0
  72. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -0
  73. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
  74. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/lib/db/database.ts +0 -0
  75. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/lib/db/index.ts +0 -0
  76. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/lib/db/progress.ts +0 -0
  77. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
  78. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/lib/types/index.ts +0 -0
  79. {learningfoundry-0.32.0/src/learningfoundry → learningfoundry-0.34.0}/sveltekit_template/src/lib/utils/markdown.ts +0 -0
  80. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/routes/+layout.svelte +0 -0
  81. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/routes/+page.svelte +0 -0
  82. {learningfoundry-0.32.0 → learningfoundry-0.34.0}/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +0 -0
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.34.0] - 2026-04-29
11
+
12
+ ### Changed
13
+
14
+ - **`learningfoundry build` now preserves install/build state across rebuilds.** Previously every rebuild wiped the entire output directory, including any existing `node_modules/`, `pnpm-lock.yaml`, `build/`, and `.svelte-kit/` — forcing the user to `pnpm install` after every regen. Now those four paths are moved into the fresh template copy before the swap, so iteration is install → build, then any number of `learningfoundry build` re-runs followed by just `pnpm build` (or `pnpm dev`).
15
+ - `src/learningfoundry/generator.py` — new `_PRESERVED_PATHS` list + `_move_preserved()` helper used by `_atomic_copy()`. Same paths are also passed to `shutil.ignore_patterns` so a stray `node_modules/` in the dev template directory never ships to user output.
16
+ - The "output directory exists" log message changed from `WARNING` ("will be overwritten") to `INFO` ("refreshing template files; preserving …") to reflect the new behaviour.
17
+
18
+ ### Added
19
+
20
+ - **Smart post-build next-steps message in the CLI** based on detected dep state:
21
+ - `FIRST_BUILD` (no `node_modules/`) → `Next: cd dist && pnpm install && pnpm build`
22
+ - `CHANGED` (any declared dep missing from `node_modules/`) → `⚠️ Dependencies changed since last install. Run: cd dist && pnpm install && pnpm build`
23
+ - `UNCHANGED` (every declared dep present) → `Next: cd dist && pnpm build`
24
+ - New public API: `learningfoundry.generator.check_dep_state(output_dir)` returning a `DepState` enum, used by the CLI but also callable from third-party tooling.
25
+
26
+ ### Added (tests)
27
+
28
+ - `tests/test_generator.py::TestPreserveInstallState` — 5 cases covering preservation of `node_modules/`, `pnpm-lock.yaml`, `build/`, `.svelte-kit/`, and confirmation that template files (e.g. `curriculum.json`) still refresh on rebuild.
29
+ - `tests/test_generator.py::TestCheckDepState` — 4 cases covering first-build, all-deps-installed, missing-dep, and malformed-`package.json` paths.
30
+
31
+ ### Performance
32
+
33
+ - Smoke build is ~40% faster (~10s vs ~17s) because the SvelteKit template's leftover dev `node_modules/` (which a developer's local pnpm runs may create in the in-repo template) is no longer copied into every `learningfoundry build` output.
34
+
35
+ ## [0.33.0] - 2026-04-29
36
+
37
+ ### Added
38
+
39
+ - **LaTeX math rendering in lesson markdown** via [KaTeX](https://katex.org/). Both inline (`$...$`) and display (`$$...$$`) syntax are supported and rendered to HTML at parse time — no runtime JS overhead per lesson view.
40
+ - `src/learningfoundry/sveltekit_template/package.json` — added `katex ^0.16.11` and `marked-katex-extension ^5.1.4` to dependencies
41
+ - `src/learningfoundry/sveltekit_template/src/lib/utils/markdown.ts` — registered `markedKatex({ throwOnError: false })` so malformed LaTeX renders the source verbatim instead of throwing
42
+ - `src/learningfoundry/sveltekit_template/src/app.css` — `@import 'katex/dist/katex.min.css';` so rendered formulas are styled
43
+
44
+ ### Added (tests)
45
+
46
+ - `src/learningfoundry/sveltekit_template/src/lib/utils/markdown.test.ts` — 6 vitest cases covering blank input, headings, fenced code, inline math, display math, and graceful malformed-LaTeX handling
47
+ - `tests/test_smoke_sveltekit.py::test_katex_styles_in_bundled_css` — regression guard asserting `.katex` rules land in the bundled CSS
48
+
10
49
  ## [0.32.0] - 2026-04-29
11
50
 
12
51
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learningfoundry
3
- Version: 0.32.0
3
+ Version: 0.34.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "learningfoundry"
7
- version = "0.32.0"
7
+ version = "0.34.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.32.0"
4
+ __version__ = "0.34.0"
@@ -110,6 +110,25 @@ def build(
110
110
 
111
111
  click.echo(f"Build complete → {output_dir}")
112
112
 
113
+ from learningfoundry.generator import DepState, check_dep_state
114
+
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("")
122
+ click.echo(
123
+ "⚠️ Dependencies changed since last install "
124
+ "(new packages in package.json)."
125
+ )
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)")
131
+
113
132
 
114
133
  # ---------------------------------------------------------------------------
115
134
  # validate
@@ -0,0 +1,193 @@
1
+ # Copyright 2026 Pointmatic
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """SvelteKit project generator — copies template and writes curriculum.json."""
4
+
5
+ import dataclasses
6
+ import json
7
+ import logging
8
+ import shutil
9
+ import tempfile
10
+ from enum import StrEnum
11
+ from pathlib import Path
12
+
13
+ from learningfoundry.exceptions import GenerationError
14
+ from learningfoundry.resolver import ResolvedCurriculum
15
+
16
+ logger = logging.getLogger("learningfoundry.generator")
17
+
18
+ _TEMPLATE_DIR = Path(__file__).parent / "sveltekit_template"
19
+
20
+ # Paths that represent install/build state in the generated project. These
21
+ # are preserved across `learningfoundry build` re-runs so the user does not
22
+ # have to `pnpm install` after every regen. Template files (package.json,
23
+ # src/, static/, configs) are still refreshed every time.
24
+ _PRESERVED_PATHS: tuple[str, ...] = (
25
+ "node_modules",
26
+ "pnpm-lock.yaml",
27
+ "build",
28
+ ".svelte-kit",
29
+ )
30
+
31
+
32
+ class DepState(StrEnum):
33
+ """State of installed dependencies relative to the generated package.json."""
34
+
35
+ FIRST_BUILD = "first_build"
36
+ """No `node_modules/` exists in the output directory."""
37
+
38
+ UNCHANGED = "unchanged"
39
+ """Every declared dep has a corresponding installed package."""
40
+
41
+ CHANGED = "changed"
42
+ """`node_modules/` exists but at least one declared dep is missing
43
+ (e.g. user upgraded `learningfoundry` and the template added a new dep)."""
44
+
45
+
46
+ def generate_app(
47
+ resolved: ResolvedCurriculum,
48
+ output_dir: Path,
49
+ template_dir: Path | None = None,
50
+ ) -> None:
51
+ """Generate a SvelteKit project from a resolved curriculum.
52
+
53
+ Copies ``sveltekit_template/`` to ``output_dir`` atomically (write to a
54
+ temp directory, then move into place), then writes ``curriculum.json``
55
+ into ``output_dir/static/``.
56
+
57
+ If ``output_dir`` already exists it is replaced with a warning.
58
+
59
+ Args:
60
+ resolved: Fully resolved curriculum from ``resolve_curriculum()``.
61
+ output_dir: Destination path for the generated SvelteKit project.
62
+ template_dir: Override template source. Defaults to the package's
63
+ ``sveltekit_template/`` directory.
64
+
65
+ Raises:
66
+ GenerationError: If the template directory does not exist or any
67
+ file operation fails.
68
+ """
69
+ src = template_dir or _TEMPLATE_DIR
70
+
71
+ if not src.exists():
72
+ raise GenerationError(
73
+ f"SvelteKit template directory not found: `{src}`. "
74
+ "Run `learningfoundry` from the project root, or pass "
75
+ "`template_dir` explicitly."
76
+ )
77
+
78
+ if output_dir.exists():
79
+ logger.info(
80
+ "Output directory `%s` already exists; refreshing template files "
81
+ "(preserving node_modules/, pnpm-lock.yaml, build/, .svelte-kit/).",
82
+ output_dir,
83
+ )
84
+
85
+ _atomic_copy(src, output_dir)
86
+ _write_curriculum_json(resolved, output_dir)
87
+
88
+ logger.info("Generated SvelteKit project at: %s", output_dir)
89
+
90
+
91
+ def _atomic_copy(src: Path, dst: Path) -> None:
92
+ """Copy src tree to dst atomically via a sibling temp directory.
93
+
94
+ If ``dst`` already exists, install/build state listed in
95
+ :data:`_PRESERVED_PATHS` is moved from the existing ``dst`` into the
96
+ fresh template copy before the swap, so users do not have to re-run
97
+ ``pnpm install`` after every ``learningfoundry build``.
98
+ """
99
+ parent = dst.parent
100
+ parent.mkdir(parents=True, exist_ok=True)
101
+
102
+ try:
103
+ with tempfile.TemporaryDirectory(dir=parent, prefix=".lf_gen_") as tmp_str:
104
+ tmp = Path(tmp_str) / dst.name
105
+ # Never ship user-side artifacts from the template, even if a
106
+ # local dev environment has them (e.g. a stray node_modules/
107
+ # from running pnpm in the template dir during development).
108
+ shutil.copytree(
109
+ src,
110
+ tmp,
111
+ ignore=shutil.ignore_patterns(*_PRESERVED_PATHS),
112
+ )
113
+ if dst.exists():
114
+ _move_preserved(dst, tmp)
115
+ shutil.rmtree(dst)
116
+ tmp.rename(dst)
117
+ except OSError as exc:
118
+ raise GenerationError(
119
+ f"Failed to generate project at `{dst}`: {exc}"
120
+ ) from exc
121
+
122
+
123
+ def _move_preserved(existing: Path, fresh: Path) -> None:
124
+ """Move install/build state from ``existing`` into ``fresh`` in place.
125
+
126
+ Any template-shipped placeholder at the same path inside ``fresh`` is
127
+ removed first so the move never collides.
128
+ """
129
+ for name in _PRESERVED_PATHS:
130
+ source = existing / name
131
+ if not source.exists() and not source.is_symlink():
132
+ continue
133
+ target = fresh / name
134
+ if target.exists() or target.is_symlink():
135
+ if target.is_dir() and not target.is_symlink():
136
+ shutil.rmtree(target)
137
+ else:
138
+ target.unlink()
139
+ shutil.move(str(source), str(target))
140
+
141
+
142
+ def check_dep_state(output_dir: Path) -> DepState:
143
+ """Inspect ``output_dir`` to determine whether `pnpm install` is needed.
144
+
145
+ A dep is considered "installed" if ``node_modules/<name>/package.json``
146
+ exists. We do not check version ranges — that is pnpm's job. This is a
147
+ presence check designed to detect the common case where the generated
148
+ `package.json` adds a new dep (e.g. after a `learningfoundry` upgrade)
149
+ that is not yet in `node_modules/`.
150
+ """
151
+ node_modules = output_dir / "node_modules"
152
+ if not node_modules.is_dir():
153
+ return DepState.FIRST_BUILD
154
+
155
+ package_json = output_dir / "package.json"
156
+ if not package_json.is_file():
157
+ return DepState.FIRST_BUILD
158
+
159
+ try:
160
+ data = json.loads(package_json.read_text(encoding="utf-8"))
161
+ except (OSError, json.JSONDecodeError):
162
+ # Malformed package.json — surface as "changed" so the user is
163
+ # nudged to run pnpm install (which will report the real error).
164
+ return DepState.CHANGED
165
+
166
+ declared: dict[str, str] = {
167
+ **(data.get("dependencies") or {}),
168
+ **(data.get("devDependencies") or {}),
169
+ }
170
+ for name in declared:
171
+ if not (node_modules / name / "package.json").is_file():
172
+ return DepState.CHANGED
173
+ return DepState.UNCHANGED
174
+
175
+
176
+ def _write_curriculum_json(resolved: ResolvedCurriculum, output_dir: Path) -> None:
177
+ """Serialize ResolvedCurriculum to output_dir/static/curriculum.json."""
178
+ static_dir = output_dir / "static"
179
+ static_dir.mkdir(parents=True, exist_ok=True)
180
+
181
+ curriculum_json = output_dir / "static" / "curriculum.json"
182
+ try:
183
+ data = dataclasses.asdict(resolved)
184
+ curriculum_json.write_text(
185
+ json.dumps(data, indent=2, ensure_ascii=False) + "\n",
186
+ encoding="utf-8",
187
+ )
188
+ except OSError as exc:
189
+ raise GenerationError(
190
+ f"Failed to write curriculum.json to `{curriculum_json}`: {exc}"
191
+ ) from exc
192
+
193
+ logger.debug("Wrote curriculum.json (%d bytes)", curriculum_json.stat().st_size)
@@ -13,8 +13,10 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "@sveltejs/kit": "^2.0.0",
16
+ "katex": "^0.16.11",
16
17
  "lucide-svelte": "^0.468.0",
17
18
  "marked": "^18.0.0",
19
+ "marked-katex-extension": "^5.1.4",
18
20
  "sql.js": "^1.12.0",
19
21
  "svelte": "^5.0.0"
20
22
  },
@@ -1,4 +1,5 @@
1
1
  /* Copyright 2026 Pointmatic */
2
2
  /* SPDX-License-Identifier: Apache-2.0 */
3
3
  @import 'tailwindcss';
4
+ @import 'katex/dist/katex.min.css';
4
5
  @plugin '@tailwindcss/typography';
@@ -0,0 +1,54 @@
1
+ // Copyright 2026 Pointmatic
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { describe, expect, it } from 'vitest';
4
+ import { renderMarkdown } from './markdown.js';
5
+
6
+ describe('renderMarkdown', () => {
7
+ it('returns empty string for null/undefined/blank input', () => {
8
+ expect(renderMarkdown(null)).toBe('');
9
+ expect(renderMarkdown(undefined)).toBe('');
10
+ expect(renderMarkdown('')).toBe('');
11
+ expect(renderMarkdown(' \n\t')).toBe('');
12
+ });
13
+
14
+ it('renders headings as <h1>/<h2>/<h3>', () => {
15
+ const html = renderMarkdown('# H1\n\n## H2\n\n### H3');
16
+ expect(html).toContain('<h1');
17
+ expect(html).toContain('<h2');
18
+ expect(html).toContain('<h3');
19
+ expect(html).toContain('H1');
20
+ expect(html).toContain('H2');
21
+ expect(html).toContain('H3');
22
+ });
23
+
24
+ it('renders fenced code blocks', () => {
25
+ const html = renderMarkdown('```python\nprint("hi")\n```');
26
+ expect(html).toContain('<pre');
27
+ expect(html).toContain('<code');
28
+ expect(html).toContain('print');
29
+ });
30
+
31
+ it('renders inline math via $...$', () => {
32
+ const html = renderMarkdown('Euler said $e^{i\\pi} + 1 = 0$.');
33
+ // KaTeX inline output wraps the formula in <span class="katex">…</span>
34
+ // (without the `katex-display` wrapper used for block-level math).
35
+ expect(html).toContain('class="katex"');
36
+ // The literal `$...$` delimiters should be consumed by the parser.
37
+ expect(html).not.toContain('$e^{i\\pi}');
38
+ });
39
+
40
+ it('renders display math via $$...$$', () => {
41
+ const md = '$$\n\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}\n$$';
42
+ const html = renderMarkdown(md);
43
+ // Display math gets the `katex-display` wrapper. KaTeX preserves the
44
+ // source LaTeX inside a MathML <annotation> tag for accessibility,
45
+ // so we don't assert that the source is absent — only that the
46
+ // rendered HTML structure is present.
47
+ expect(html).toContain('katex-display');
48
+ expect(html).toContain('class="katex"');
49
+ });
50
+
51
+ it('does not throw on malformed LaTeX (throwOnError: false)', () => {
52
+ expect(() => renderMarkdown('$\\unknownmacro{x}$')).not.toThrow();
53
+ });
54
+ });
@@ -1,6 +1,12 @@
1
1
  // Copyright 2026 Pointmatic
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { marked } from 'marked';
4
+ import markedKatex from 'marked-katex-extension';
5
+
6
+ // Register the KaTeX extension once at module load. Supports both inline
7
+ // `$...$` and display `$$...$$` math, rendered to HTML at parse time using
8
+ // the KaTeX engine. Stylesheet is imported in `src/app.css`.
9
+ marked.use(markedKatex({ throwOnError: false }));
4
10
 
5
11
  /**
6
12
  * Convert a markdown string to sanitised HTML.
@@ -1,99 +0,0 @@
1
- # Copyright 2026 Pointmatic
2
- # SPDX-License-Identifier: Apache-2.0
3
- """SvelteKit project generator — copies template and writes curriculum.json."""
4
-
5
- import dataclasses
6
- import json
7
- import logging
8
- import shutil
9
- import tempfile
10
- from pathlib import Path
11
-
12
- from learningfoundry.exceptions import GenerationError
13
- from learningfoundry.resolver import ResolvedCurriculum
14
-
15
- logger = logging.getLogger("learningfoundry.generator")
16
-
17
- _TEMPLATE_DIR = Path(__file__).parent / "sveltekit_template"
18
-
19
-
20
- def generate_app(
21
- resolved: ResolvedCurriculum,
22
- output_dir: Path,
23
- template_dir: Path | None = None,
24
- ) -> None:
25
- """Generate a SvelteKit project from a resolved curriculum.
26
-
27
- Copies ``sveltekit_template/`` to ``output_dir`` atomically (write to a
28
- temp directory, then move into place), then writes ``curriculum.json``
29
- into ``output_dir/static/``.
30
-
31
- If ``output_dir`` already exists it is replaced with a warning.
32
-
33
- Args:
34
- resolved: Fully resolved curriculum from ``resolve_curriculum()``.
35
- output_dir: Destination path for the generated SvelteKit project.
36
- template_dir: Override template source. Defaults to the package's
37
- ``sveltekit_template/`` directory.
38
-
39
- Raises:
40
- GenerationError: If the template directory does not exist or any
41
- file operation fails.
42
- """
43
- src = template_dir or _TEMPLATE_DIR
44
-
45
- if not src.exists():
46
- raise GenerationError(
47
- f"SvelteKit template directory not found: `{src}`. "
48
- "Run `learningfoundry` from the project root, or pass "
49
- "`template_dir` explicitly."
50
- )
51
-
52
- if output_dir.exists():
53
- logger.warning(
54
- "Output directory `%s` already exists and will be overwritten.",
55
- output_dir,
56
- )
57
-
58
- _atomic_copy(src, output_dir)
59
- _write_curriculum_json(resolved, output_dir)
60
-
61
- logger.info("Generated SvelteKit project at: %s", output_dir)
62
-
63
-
64
- def _atomic_copy(src: Path, dst: Path) -> None:
65
- """Copy src tree to dst atomically via a sibling temp directory."""
66
- parent = dst.parent
67
- parent.mkdir(parents=True, exist_ok=True)
68
-
69
- try:
70
- with tempfile.TemporaryDirectory(dir=parent, prefix=".lf_gen_") as tmp_str:
71
- tmp = Path(tmp_str) / dst.name
72
- shutil.copytree(src, tmp)
73
- if dst.exists():
74
- shutil.rmtree(dst)
75
- tmp.rename(dst)
76
- except OSError as exc:
77
- raise GenerationError(
78
- f"Failed to generate project at `{dst}`: {exc}"
79
- ) from exc
80
-
81
-
82
- def _write_curriculum_json(resolved: ResolvedCurriculum, output_dir: Path) -> None:
83
- """Serialize ResolvedCurriculum to output_dir/static/curriculum.json."""
84
- static_dir = output_dir / "static"
85
- static_dir.mkdir(parents=True, exist_ok=True)
86
-
87
- curriculum_json = output_dir / "static" / "curriculum.json"
88
- try:
89
- data = dataclasses.asdict(resolved)
90
- curriculum_json.write_text(
91
- json.dumps(data, indent=2, ensure_ascii=False) + "\n",
92
- encoding="utf-8",
93
- )
94
- except OSError as exc:
95
- raise GenerationError(
96
- f"Failed to write curriculum.json to `{curriculum_json}`: {exc}"
97
- ) from exc
98
-
99
- logger.debug("Wrote curriculum.json (%d bytes)", curriculum_json.stat().st_size)