Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,22 @@ templates/wheels.yml # optional caller for course-wheels.yml
(editable cells replace JupyterLite's power button) is in the kit's
`site.css`; courses no longer carry it.

## Content roots (internals)

- Pages live in **content roots**: `tutorials` (`nma.paths.tutorials`) and
`projects` (`nma.paths.projects`). `Settings.content_roots` /
`contentRoots()` list `(name, directory)`; the *name* is fixed, the
directory is configurable. A missing directory is just empty.
- Everything generated is scoped by the root name:
`notebooks/<root>/<relDir>/<stem>.ipynb` (+ `instructor/`, `solutions/`),
`_build/scrolly/<root>/<relDir>/<stem>.json`. `page_location` /
`pageLocation` return the root too, and `PageLocation.notebook_dir` /
`notebookDir()` give the folder under `notebooks/`. The deepest matching
directory wins when roots are nested.
- `nma-kit notebooks | prepare-site | render-scrolly | check` all go through
`process_content` / the roots, so a feature added to tutorials works in projects.
The twins are tested with `tests/fixtures/config/settings.json`.

## Site branding (internals)

- **Source of truth**: `python/src/nma_kit/assets/` (`site.css`, logos, favicon,
Expand All @@ -119,7 +135,7 @@ templates/wheels.yml # optional caller for course-wheels.yml
change), `--nma-cell-*` and `--nma-tok-*` (read by `cells/assets/cells.css`).
Syntax-token colours are fixed on purpose (chosen for contrast).
- Settings are validated by `branding.brand_errors` inside `check`.
- `tests/scripts/build-example.sh` ends with `git checkout -- tutorials myst.yml`
- `tests/scripts/build-example.sh` ends with `git checkout -- tutorials projects myst.yml`
to undo `prepare-site`: stage (`git add`) edits to the example course's
`myst.yml` before running it, or they are reverted too.

Expand Down Expand Up @@ -164,7 +180,7 @@ templates/wheels.yml # optional caller for course-wheels.yml
footer links; regression test in `tests/js/scrolly.test.mjs`).
- `nma-kit render-scrolly` runs each scrolly page's instructor notebook
(exercise cells removed, `# @param` at defaults, cwd = page dir) and writes
`_build/scrolly/<relDir>/<stem>.json` = `{cell_key: {stdout, images, error}}`.
`_build/scrolly/<root>/<relDir>/<stem>.json` = `{cell_key: {stdout, images, error}}`.
A raising cell fails CI with `file:line`.

## Commands
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ Optional settings, in the `nma:` block of `myst.yml` (defaults shown, except
```yaml
nma:
paths:
tutorials: tutorials # where the .md pages live
tutorials: tutorials # where the tutorial .md pages live
projects: projects # where the project .md pages live
notebooks:
branch: notebooks-branch # where generated notebooks are published
feedback: # needed if any page uses {nma-feedback}
Expand Down
1 change: 1 addition & 0 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Settings live in the `nma:` block of `myst.yml` (all optional):
nma:
paths:
tutorials: tutorials
projects: projects
notebooks:
branch: notebooks-branch
feedback: { url: https://..., project: ..., user_key: ... }
Expand Down
6 changes: 3 additions & 3 deletions js/src/scrolly/plugin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -420,12 +420,12 @@ function scrollyConfig() {
return { pyodideUrl: courseSettings().nma.scrolly?.pyodide_url || DEFAULT_PYODIDE_URL };
}

// tutorials/W2D1_Examples/W2D1_Tutorial2.md -> _build/scrolly/W2D1_Examples/W2D1_Tutorial2.json
// (same as render_path in nma_kit.scrolly; the tutorials dir comes from myst.yml)
// tutorials/W2D1_Examples/W2D1_Tutorial2.md -> _build/scrolly/tutorials/W2D1_Examples/W2D1_Tutorial2.json
// (same as render_path in nma_kit.scrolly; the content dirs come from myst.yml)
function renderFile(filePath) {
const location = pageLocation(filePath);
const parts = location
? [...(location.relDir ? location.relDir.split('/') : []), location.stem]
? [location.root, ...(location.relDir ? location.relDir.split('/') : []), location.stem]
: relative(process.cwd(), filePath).split(sep).join('/').replace(/\.[^.]+$/, '').split('/');
const stem = parts.pop();
return join(process.cwd(), '_build', 'scrolly', ...parts, `${stem}.json`);
Expand Down
7 changes: 5 additions & 2 deletions js/src/shared/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
//
// nma:
// paths:
// tutorials: tutorials # where the course's .md pages live
// tutorials: tutorials # where the course's tutorial .md pages live
// projects: projects # where the course's project .md pages live
// notebooks:
// branch: notebooks-branch # where generated notebooks are published

import { parse } from 'yaml';

export const DEFAULT_TUTORIALS_DIR = 'tutorials';
export const DEFAULT_PROJECTS_DIR = 'projects';
export const DEFAULT_NOTEBOOKS_BRANCH = 'notebooks-branch';
const GITHUB_RE = /github\.com\/([^\s'"]+?)\/?$/;

Expand All @@ -36,7 +38,7 @@ function setting(nma, section, key, fallback) {
return typeof value === 'string' && value.trim() ? value : fallback;
}

/** {githubRepo, tutorialsDir, notebooksBranch, nma} from myst.yml text. */
/** {githubRepo, tutorialsDir, projectsDir, notebooksBranch, nma} from myst.yml text. */
export function loadSettings(text) {
const data = parseYaml(text);
const nma = readNmaConfig(text);
Expand All @@ -45,6 +47,7 @@ export function loadSettings(text) {
return {
githubRepo: match ? match[1] : null,
tutorialsDir: setting(nma, 'paths', 'tutorials', DEFAULT_TUTORIALS_DIR).replace(/^\/+|\/+$/g, ''),
projectsDir: setting(nma, 'paths', 'projects', DEFAULT_PROJECTS_DIR).replace(/^\/+|\/+$/g, ''),
notebooksBranch: setting(nma, 'notebooks', 'branch', DEFAULT_NOTEBOOKS_BRANCH),
nma,
};
Expand Down
32 changes: 23 additions & 9 deletions js/src/shared/paths.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,30 +28,44 @@ export function courseSettings() {

export const githubRepo = () => courseSettings().githubRepo;

// tutorials/W2D1_Examples/W2D1_Tutorial1.md -> { relDir: 'W2D1_Examples', stem: 'W2D1_Tutorial1' }
/** The (name, directory) of each place pages live; twin of Settings.content_roots. */
export function contentRoots() {
const { tutorialsDir, projectsDir } = courseSettings();
return [['tutorials', tutorialsDir], ['projects', projectsDir]];
}

/** Folder under notebooks/ holding a page's generated files. */
export const notebookDir = ({ root, relDir }) => (relDir ? `${root}/${relDir}` : root);

// tutorials/W2D1_Examples/W2D1_Tutorial1.md -> { root: 'tutorials', relDir: 'W2D1_Examples', stem: 'W2D1_Tutorial1' }
// The deepest matching content root wins (twin of page_location).
export function pageLocation(filePath) {
const prefix = `${courseSettings().tutorialsDir}/`;
const rel = relative(process.cwd(), filePath).split(sep).join('/');
if (!rel.startsWith(prefix)) return null;
const parts = rel.slice(prefix.length).split('/');
const stem = parts.pop().replace(/\.[^.]+$/, '');
return { relDir: parts.join('/'), stem };
const roots = contentRoots().sort((a, b) => b[1].length - a[1].length);
for (const [root, dir] of roots) {
const prefix = `${dir}/`;
if (!rel.startsWith(prefix)) continue;
const parts = rel.slice(prefix.length).split('/');
const stem = parts.pop().replace(/\.[^.]+$/, '');
return { root, relDir: parts.join('/'), stem };
}
return null;
}

/** Colab and Kaggle URLs of a page's generated notebook (same as make_badge_html). */
export function notebookUrls(filePath) {
const { githubRepo: repo, notebooksBranch: branch } = courseSettings();
const location = pageLocation(filePath);
if (!repo || !location) return { colabUrl: 'https://colab.research.google.com/', kaggleUrl: 'https://www.kaggle.com/' };
const path = ['notebooks', location.relDir, `${location.stem}.ipynb`].filter(Boolean).join('/');
const path = ['notebooks', notebookDir(location), `${location.stem}.ipynb`].filter(Boolean).join('/');
return {
colabUrl: `https://colab.research.google.com/github/${repo}/blob/${branch}/${path}`,
kaggleUrl: `https://kaggle.com/kernels/welcome?src=https://raw.githubusercontent.com/${repo}/${branch}/${path}`,
};
}

/** GitHub URL of a solution/explanation file on the notebooks branch. */
export function solutionUrl(repo, relDir, filename, branch) {
const path = ['notebooks', relDir, 'solutions', filename].filter(Boolean).join('/');
export function solutionUrl(repo, nbDir, filename, branch) {
const path = ['notebooks', nbDir, 'solutions', filename].filter(Boolean).join('/');
return `https://github.com/${repo}/blob/${branch}/${path}`;
}
6 changes: 3 additions & 3 deletions js/src/solutions/plugin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// placeholders in document order and builds the links. The file naming and URL
// rules must stay in sync with nma_kit.solutions.

import { courseSettings, pageLocation, solutionUrl } from '../shared/paths.mjs';
import { contentRoots, courseSettings, notebookDir, pageLocation, solutionUrl } from '../shared/paths.mjs';
import { findPlaceholders, replaceNode, text } from '../shared/tree.mjs';

const SOLUTION_KINDS = {
Expand Down Expand Up @@ -56,12 +56,12 @@ const solutionLinks = {
continue;
}
if (!location) {
fatal(`${kind} is only supported in pages under ${courseSettings().tutorialsDir}/`);
fatal(`${kind} is only supported in pages under ${contentRoots().map(([, dir]) => `${dir}/`).join(' or ')}`);
continue;
}
counters[kind] = (counters[kind] ?? 0) + 1;
const { label, file } = SOLUTION_KINDS[kind];
const url = solutionUrl(repo, location.relDir, file(location.stem, counters[kind]), branch);
const url = solutionUrl(repo, notebookDir(location), file(location.stem, counters[kind]), branch);
replaceWith(
[
{
Expand Down
5 changes: 3 additions & 2 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ folder with `myst.yml`).
| Command | What it does |
|---|---|
| `nma-kit check` | Validate the content rules and the `nma:` block of `myst.yml` |
| `nma-kit notebooks [--dry-run]` | Generate student and instructor notebooks and the solution files into `notebooks/` |
| `nma-kit notebooks [--dry-run]` | Generate student and instructor notebooks and the solution files into `notebooks/<tutorials|projects>/...` |
| `nma-kit sync-assets` | Write the site branding (stylesheet, logos, favicon, Rubik font) to `_nma/`, which the course's `myst.yml` points at |
| `nma-kit render-scrolly` | Run each scrolly page's instructor notebook and save outputs to `_build/scrolly/` |
| `nma-kit prepare-site [--dry-run] [--kit-version V]` | Add badges and fallback cells to the pages, pin the kit URL in `myst.yml` and run `sync-assets`. **CI only: it edits files in place, never commit the result** |
Expand All @@ -41,7 +41,8 @@ top-level `nma:` block holds the rest.
```yaml
nma:
paths:
tutorials: tutorials # where the .md pages live
tutorials: tutorials # where the tutorial .md pages live
projects: projects # where the project .md pages live
notebooks:
branch: notebooks-branch # where generated notebooks are published
feedback: # required if any page uses {nma-feedback}
Expand Down
6 changes: 3 additions & 3 deletions python/src/nma_kit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,13 @@ def cmd_notebooks(args) -> int:
settings = _settings()
if not _validate(check_injected=True):
return 1
count = convert.process_tutorials(settings, args.dry_run, prepare_site=False)
count = convert.process_content(settings, args.dry_run, prepare_site=False)
print(f"Done. Processed {count} file(s).")
return 0


def cmd_render_scrolly(args) -> int:
return scrolly.render_all(read_settings().tutorials_dir)
return scrolly.render_all(read_settings())


def cmd_sync_assets(args) -> int:
Expand All @@ -78,7 +78,7 @@ def cmd_prepare_site(args) -> int:
settings = _settings()
if not _validate(check_injected=False):
return 1
count = convert.process_tutorials(settings, args.dry_run, prepare_site=True)
count = convert.process_content(settings, args.dry_run, prepare_site=True)
if not args.dry_run:
branding.sync_assets(settings)
text = MYST_CONFIG.read_text(encoding="utf-8")
Expand Down
45 changes: 35 additions & 10 deletions python/src/nma_kit/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,21 @@

nma:
paths:
tutorials: tutorials # where the course's .md pages live
tutorials: tutorials # where the course's tutorial .md pages live
projects: projects # where the course's project .md pages live
notebooks:
branch: notebooks-branch # where generated notebooks are published
"""

import re
from dataclasses import dataclass, field
from typing import NamedTuple
from pathlib import Path, PurePosixPath

import yaml

DEFAULT_TUTORIALS_DIR = "tutorials"
DEFAULT_PROJECTS_DIR = "projects"
DEFAULT_NOTEBOOKS_BRANCH = "notebooks-branch"
NOTEBOOKS_DIR = "notebooks"
MYST_CONFIG = Path("myst.yml")
Expand All @@ -44,6 +47,13 @@ class Settings:
tutorials_dir: str = DEFAULT_TUTORIALS_DIR
notebooks_branch: str = DEFAULT_NOTEBOOKS_BRANCH
nma: dict = field(default_factory=dict)
projects_dir: str = DEFAULT_PROJECTS_DIR

@property
def content_roots(self) -> tuple[tuple[str, str], ...]:
"""(name, directory) of each place pages live. The name is fixed (it is
the first folder under notebooks/); the directory is configurable."""
return (("tutorials", self.tutorials_dir), ("projects", self.projects_dir))


def _yaml(text: str) -> dict:
Expand Down Expand Up @@ -80,6 +90,7 @@ def load_settings(myst_text: str) -> Settings:
tutorials_dir=_setting(nma, "paths", "tutorials", DEFAULT_TUTORIALS_DIR).strip("/"),
notebooks_branch=_setting(nma, "notebooks", "branch", DEFAULT_NOTEBOOKS_BRANCH),
nma=nma,
projects_dir=_setting(nma, "paths", "projects", DEFAULT_PROJECTS_DIR).strip("/"),
)


Expand All @@ -90,13 +101,27 @@ def read_settings(path: Path = MYST_CONFIG) -> Settings:
return load_settings(path.read_text(encoding="utf-8"))


def page_location(settings: Settings, page: str | Path) -> tuple[str, str] | None:
"""(rel_dir, stem) of a page path relative to the course root, or None if
it is outside the tutorials dir. 'tutorials/W1/T1.md' -> ('W1', 'T1')."""
class PageLocation(NamedTuple):
root: str # content root name: 'tutorials' or 'projects'
rel_dir: str # folder under the content root ('' at the top)
stem: str

@property
def notebook_dir(self) -> str:
"""Folder under notebooks/ holding the page's generated files."""
return f"{self.root}/{self.rel_dir}" if self.rel_dir else self.root


def page_location(settings: Settings, page: str | Path) -> PageLocation | None:
"""Where a page (path relative to the course root) lives, or None if it is
outside every content root. 'tutorials/W1/T1.md' -> PageLocation('tutorials', 'W1', 'T1').
The deepest matching root wins."""
path = PurePosixPath(Path(page).as_posix())
try:
rel = path.relative_to(settings.tutorials_dir)
except ValueError:
return None
rel_dir = rel.parent.as_posix()
return ("" if rel_dir == "." else rel_dir), rel.stem
for name, directory in sorted(settings.content_roots, key=lambda r: -len(r[1])):
try:
rel = path.relative_to(directory)
except ValueError:
continue
rel_dir = rel.parent.as_posix()
return PageLocation(name, "" if rel_dir == "." else rel_dir, rel.stem)
return None
Loading
Loading