@lernapps/tooling 0.4.0 → 0.5.0

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 (44) hide show
  1. package/dist/check/rules/competencies-on-map.mjs +85 -0
  2. package/dist/check/rules/one-competency-per-page.mjs +18 -0
  3. package/dist/check/rules/prerequisites-linked.mjs +38 -0
  4. package/dist/cli.mjs +191 -52
  5. package/dist/explainer-map-DW4XCcY0.mjs +124 -0
  6. package/dist/skills/lernapps-app/SKILL.md +2 -1
  7. package/dist/skills/lernapps-cli/SKILL.md +13 -4
  8. package/dist/skills/lernapps-content-questions/SKILL.md +2 -0
  9. package/dist/skills/lernapps-content-questions/references/question-types.md +2 -1
  10. package/dist/skills/lernapps-content-research/SKILL.md +41 -5
  11. package/dist/skills/lernapps-exercise-patterns/references/mathematics.md +25 -1
  12. package/dist/skills/lernapps-exercise-patterns/references/physics.md +1 -0
  13. package/dist/skills/lernapps-explainer/SKILL.md +10 -4
  14. package/dist/skills/lernapps-explainer/references/blocks.md +7 -3
  15. package/dist/skills/lernapps-explainer/references/exercises.md +6 -0
  16. package/dist/skills/lernapps-explainer/references/rules.md +29 -0
  17. package/dist/skills/lernapps-explainer/references/structure.md +62 -0
  18. package/dist/skills/lernapps-explainer-design/SKILL.md +46 -2
  19. package/dist/skills/lernapps-explainer-design/references/rules.md +1 -0
  20. package/dist/skills/lernapps-explainer-self-assessment/SKILL.md +1 -0
  21. package/dist/skills/lernapps-listing/SKILL.md +9 -0
  22. package/generator/create.ts +39 -10
  23. package/generator/init.ts +89 -19
  24. package/generator/update.ts +53 -8
  25. package/guidance/increment-template.md +13 -5
  26. package/guidance/plan-template.md +25 -11
  27. package/guidance/workflow-rules.md +3 -1
  28. package/package.json +2 -2
  29. package/skills/archetypes/lernapps-explainer/SKILL.md +10 -4
  30. package/skills/archetypes/lernapps-explainer/references/blocks.md +7 -3
  31. package/skills/archetypes/lernapps-explainer/references/exercises.md +6 -0
  32. package/skills/archetypes/lernapps-explainer/references/rules.md +29 -0
  33. package/skills/archetypes/lernapps-explainer/references/structure.md +62 -0
  34. package/skills/archetypes/lernapps-explainer-design/SKILL.md +46 -2
  35. package/skills/archetypes/lernapps-explainer-design/references/rules.md +1 -0
  36. package/skills/archetypes/lernapps-explainer-self-assessment/SKILL.md +1 -0
  37. package/skills/content/lernapps-content-questions/SKILL.md +2 -0
  38. package/skills/content/lernapps-content-questions/references/question-types.md +2 -1
  39. package/skills/content/lernapps-content-research/SKILL.md +41 -5
  40. package/skills/content/lernapps-exercise-patterns/references/mathematics.md +25 -1
  41. package/skills/content/lernapps-exercise-patterns/references/physics.md +1 -0
  42. package/skills/process/lernapps-app/SKILL.md +2 -1
  43. package/skills/process/lernapps-listing/SKILL.md +9 -0
  44. package/skills/tooling/lernapps-cli/SKILL.md +13 -4
package/dist/cli.mjs CHANGED
@@ -74,7 +74,7 @@ function nodeMajorProblem(version = process.versions.node) {
74
74
  severity: "warning"
75
75
  };
76
76
  }
77
- const git$2 = (cwd, ...args) => {
77
+ const git$3 = (cwd, ...args) => {
78
78
  const result = spawnSync("git", args, {
79
79
  cwd,
80
80
  encoding: "utf8"
@@ -83,7 +83,7 @@ const git$2 = (cwd, ...args) => {
83
83
  };
84
84
  /** The folder of the cache and the lock: <git common dir>/lernapps, or undefined outside a repository. */
85
85
  function stateDir(cwd = ".") {
86
- const common = git$2(cwd, "rev-parse", "--git-common-dir")?.trim();
86
+ const common = git$3(cwd, "rev-parse", "--git-common-dir")?.trim();
87
87
  return common === void 0 || common === "" ? void 0 : join(resolve(cwd, common), "lernapps");
88
88
  }
89
89
  /**
@@ -92,11 +92,11 @@ function stateDir(cwd = ".") {
92
92
  */
93
93
  function cacheKey(cwd, parts, toolingVersion, env = process.env) {
94
94
  if (env["CI"] !== void 0 && env["CI"] !== "" && env["CI"] !== "false") return { none: "CI checks every time" };
95
- const top = git$2(cwd, "rev-parse", "--show-toplevel")?.trim();
95
+ const top = git$3(cwd, "rev-parse", "--show-toplevel")?.trim();
96
96
  if (top === void 0) return { none: "not a git repository" };
97
- const tree = git$2(cwd, "rev-parse", "--verify", "--quiet", "HEAD^{tree}")?.trim();
97
+ const tree = git$3(cwd, "rev-parse", "--verify", "--quiet", "HEAD^{tree}")?.trim();
98
98
  if (tree === void 0 || tree === "") return { none: "no commit yet" };
99
- const status = git$2(top, "status", "--porcelain", "--untracked-files=all");
99
+ const status = git$3(top, "status", "--porcelain", "--untracked-files=all");
100
100
  if (status === void 0) return { none: "git status failed" };
101
101
  if (status.trim() !== "") return { none: "the working tree has changes that are not committed" };
102
102
  const variables = Object.keys(env).filter((name) => name.startsWith("LERNAPPS_")).sort().map((name) => `${name}=${env[name]}`);
@@ -878,7 +878,9 @@ In a repo (no <dir|url>):
878
878
  .arc42ignore does not list; a hint to review an element under whose path code changed is a
879
879
  warning. Before the first commit arc42 validate --strict
880
880
  --pre-push the heavy part: in an app with an architecture arc42 validate --strict, unit tests, build (vp build for an app with index.html, else vp pack),
881
- checks of the built app in dist/, the Playwright tests when playwright.config.* exists,
881
+ checks of the built app in dist/, of an explainer in format 2 its strand, page
882
+ competencies, recap pages and prerequisites of other strands against the learning map
883
+ (https://lernapps.net/apps/map/), the Playwright tests when playwright.config.* exists,
882
884
  dependency licences, the lock file (the pre-push hook); a failure increments prePushFailures in the front
883
885
  matter of the current plan (the highest number in .vibe/plans/, else .vibe/plan.md). A
884
886
  failing generic test of the archetype's runtime (@lernapps/app-templates) is named as a test
@@ -1051,8 +1053,9 @@ details of a figure, such as where a line of a drawing points, in its own image.
1051
1053
  send images: the images are PNG files, and an index.html shows them all in page order with each page's title and path
1052
1054
  (one file, images relative, no network).
1053
1055
 
1054
- The built app is <dir>, default dist/ (build it first). Its pages are those lernapps check visits; the start page
1055
- comes first. Each is opened in Chromium, as a learner first sees it: nothing answered, no hover, animations off,
1056
+ The built app is <dir> (build it first); default dist/ (npx vp build, as lernapps check builds) or _site/ (npm run
1057
+ build), whichever has an index.html, the newer when both have one. Its pages are those lernapps check visits; the
1058
+ start page comes first. Each is opened in Chromium, as a learner first sees it: nothing answered, no hover, animations off,
1056
1059
  fonts and images loaded; requests to other hosts are blocked. A figure is a block figure or labelled-figure, or any
1057
1060
  other figure with an image or a drawing. It is cut out with its caption, at twice its size on screen, from the page
1058
1061
  as the build writes it, without JavaScript, so that the figures of every question of a quiz are there too; a figure
@@ -1318,24 +1321,24 @@ function loadRules(dir) {
1318
1321
  };
1319
1322
  }
1320
1323
  const ZERO = /^0+$/;
1321
- const git$1 = (cwd, ...args) => spawnSync("git", args, {
1324
+ const git$2 = (cwd, ...args) => spawnSync("git", args, {
1322
1325
  cwd,
1323
1326
  encoding: "utf8",
1324
1327
  maxBuffer: 67108864
1325
1328
  });
1326
1329
  /** The paths of `git diff --name-only -z`; renames as a deletion and an addition, so both paths count. */
1327
1330
  function diffNames(cwd, ...args) {
1328
- const diff = git$1(cwd, "diff", "--name-only", "--no-renames", "-z", ...args);
1331
+ const diff = git$2(cwd, "diff", "--name-only", "--no-renames", "-z", ...args);
1329
1332
  if (diff.error || diff.status !== 0) return void 0;
1330
1333
  return diff.stdout.split("\0").filter((path) => path !== "");
1331
1334
  }
1332
- const isCommit = (cwd, rev) => git$1(cwd, "cat-file", "-e", `${rev}^{commit}`).status === 0;
1335
+ const isCommit = (cwd, rev) => git$2(cwd, "cat-file", "-e", `${rev}^{commit}`).status === 0;
1333
1336
  /**
1334
1337
  * What a new branch is compared with: its merge base with origin/main; when the remote has no main yet (the first push
1335
1338
  * of a new repo), the empty tree, so the branch adds everything it holds (nothing, for an empty start commit).
1336
1339
  */
1337
1340
  function newBranchBase(cwd, local) {
1338
- if (git$1(cwd, "rev-parse", "--verify", "--quiet", "origin/main").status !== 0) {
1341
+ if (git$2(cwd, "rev-parse", "--verify", "--quiet", "origin/main").status !== 0) {
1339
1342
  const empty = spawnSync("git", [
1340
1343
  "hash-object",
1341
1344
  "-t",
@@ -1352,7 +1355,7 @@ function newBranchBase(cwd, local) {
1352
1355
  };
1353
1356
  }
1354
1357
  return {
1355
- base: git$1(cwd, "merge-base", local, "origin/main").stdout.trim(),
1358
+ base: git$2(cwd, "merge-base", local, "origin/main").stdout.trim(),
1356
1359
  tree: false
1357
1360
  };
1358
1361
  }
@@ -1385,8 +1388,8 @@ function pushedFiles(lines, cwd = ".") {
1385
1388
  }
1386
1389
  /** Fetches the whole history once, when the clone is shallow (as actions/checkout makes it by default). */
1387
1390
  function unshallow(cwd) {
1388
- if (git$1(cwd, "rev-parse", "--is-shallow-repository").stdout.trim() !== "true") return false;
1389
- return git$1(cwd, "fetch", "--quiet", "--no-tags", "--unshallow", "origin").status === 0;
1391
+ if (git$2(cwd, "rev-parse", "--is-shallow-repository").stdout.trim() !== "true") return false;
1392
+ return git$2(cwd, "fetch", "--quiet", "--no-tags", "--unshallow", "origin").status === 0;
1390
1393
  }
1391
1394
  /**
1392
1395
  * The files between two commits: with `mergeBase`, what `head` adds to its merge base with `base` (a pull request);
@@ -1396,7 +1399,7 @@ function unshallow(cwd) {
1396
1399
  function rangeFiles(base, head, mergeBase, cwd = ".") {
1397
1400
  const attempt = () => {
1398
1401
  if (!isCommit(cwd, base) || !isCommit(cwd, head)) return void 0;
1399
- const from = mergeBase ? git$1(cwd, "merge-base", base, head).stdout.trim() : base;
1402
+ const from = mergeBase ? git$2(cwd, "merge-base", base, head).stdout.trim() : base;
1400
1403
  return from === "" ? void 0 : diffNames(cwd, from, head);
1401
1404
  };
1402
1405
  return attempt() ?? (unshallow(cwd) ? attempt() : void 0);
@@ -1658,7 +1661,8 @@ node_modules init wrote). From the installed package @lernapps/app-templates, th
1658
1661
  on, it writes the archetype's scaffold: the files of the template folder <name>/ that the package's lernapps.scaffold
1659
1662
  lists (the entry, the configuration, the end-to-end tests of the content) and a placeholder as start content
1660
1663
  (scaffold/<name>/), which the planned content replaces. The template folder itself is the example app: it stays in
1661
- node_modules/@lernapps/app-templates/<name>/, to look up. The app depends on the runtime of the same version.
1664
+ node_modules/@lernapps/app-templates/<name>/, to look up. The app depends on the runtime of the same version (or
1665
+ the same commit, while @lernapps/tooling depends on an unreleased one).
1662
1666
 
1663
1667
  It needs the plan of the first version, confirmed by the creator: started from the plan template as
1664
1668
  .vibe/plans/01-<short name>.md, the only plan there, worked through Explore and Plan, now in the phase code
@@ -1683,7 +1687,10 @@ publish action to the repo's GitHub Pages), an issue form for content errors, LI
1683
1687
  Renovate preset of lernapps). The installed skills and skills-lock.json are committed with the app, like the
1684
1688
  content guide; lernapps update replaces the lernapps-* skills.
1685
1689
 
1686
- Then: git init (if <dir> is not a repository yet), npm install (installs the git hooks), and the first commit.
1690
+ It installs nothing and commits nothing. Its last lines say what to do next, in this order: git init
1691
+ --initial-branch=work (only if <dir> is not a repository yet; lernapps init made one), npm install (installs the
1692
+ runtime and the git hooks), git add -A and git commit (the first commit, on work; the hooks check it), then follow
1693
+ the plan.
1687
1694
 
1688
1695
  Exit codes: 0 when the app is written, 1 when it cannot be (the message says why), 2 on a usage error.
1689
1696
 
@@ -1693,8 +1700,8 @@ Options:
1693
1700
  its file name <NN>-<short name>.md, else 01-plan.md (default: the one in <dir>)
1694
1701
  --templates <dir> the package @lernapps/app-templates to copy from: its folder, or a clone of its repository
1695
1702
  (default: the environment variable LERNAPPS_TEMPLATES, else the installed package)
1696
- --tooling <spec> the npm spec of @lernapps/tooling the app depends on (default: ^ the version of this
1697
- @lernapps/tooling)
1703
+ --tooling <spec> the npm spec of @lernapps/tooling the app depends on (default: the one lernapps init
1704
+ installed, else ^ the version of this @lernapps/tooling)
1698
1705
  -h, --help print this help
1699
1706
  `;
1700
1707
  /** The generator cannot write the app; the message says why and what to do. */
@@ -1726,6 +1733,17 @@ function templatesPackage(dir) {
1726
1733
  scaffold: manifest.lernapps?.scaffold ?? {}
1727
1734
  };
1728
1735
  }
1736
+ /** The version of a package built from a commit, not released: its package.json in the repository says 0.0.0. */
1737
+ const UNRELEASED = "0.0.0";
1738
+ /**
1739
+ * The spec of the runtime an app depends on, for templates of `version`: ^ that version; for templates built from a
1740
+ * commit (version 0.0.0, which the registry does not have) the spec this tooling depends on, the same commit
1741
+ * (github:lernapps/app-templates#<commit>), so that template and runtime match.
1742
+ */
1743
+ function runtimeSpec(version) {
1744
+ if (version !== UNRELEASED) return `^${version}`;
1745
+ return readJson(fromPackage("package.json")).dependencies?.["@lernapps/app-templates"] ?? `^${version}`;
1746
+ }
1729
1747
  /** The version of this package. */
1730
1748
  const ownVersion = () => readJson(fromPackage("package.json")).version ?? "0.0.0";
1731
1749
  /** The lock file of `npx skills add`, next to the skills it installed. */
@@ -1758,7 +1776,7 @@ function contents(dir) {
1758
1776
  return [name];
1759
1777
  });
1760
1778
  }
1761
- function git(args, cwd) {
1779
+ function git$1(args, cwd) {
1762
1780
  const result = spawnSync("git", args, {
1763
1781
  cwd,
1764
1782
  encoding: "utf8"
@@ -1792,6 +1810,8 @@ function writeScaffold(templates, archetype, dir) {
1792
1810
  if (!existsSync(start)) throw new CreateError(`${templates.root} has no start content scaffold/${archetype}/`);
1793
1811
  cpSync(start, dir, { recursive: true });
1794
1812
  }
1813
+ /** The branch the plan works on (rule 8 of the plan template). */
1814
+ const WORK_BRANCH = "work";
1795
1815
  /** This package, as an app depends on it. */
1796
1816
  const TOOLING = "@lernapps/tooling";
1797
1817
  /**
@@ -1957,7 +1977,7 @@ function create(args) {
1957
1977
  };
1958
1978
  if (app.devDependencies?.["@lernapps/app-templates"] !== void 0) app.devDependencies = {
1959
1979
  ...app.devDependencies,
1960
- [RUNTIME]: `^${templates.version}`
1980
+ [RUNTIME]: runtimeSpec(templates.version)
1961
1981
  };
1962
1982
  const overrides = app["overrides"];
1963
1983
  if (typeof overrides === "object" && overrides !== null && "@lernapps/app-templates" in overrides) {
@@ -1965,7 +1985,7 @@ function create(args) {
1965
1985
  if (Object.keys(kept).length > 0) app["overrides"] = kept;
1966
1986
  else delete app["overrides"];
1967
1987
  }
1968
- const tooling = values.tooling ?? `^${ownVersion()}`;
1988
+ const tooling = values.tooling ?? initialised?.devDependencies?.["@lernapps/tooling"] ?? `^${ownVersion()}`;
1969
1989
  app.devDependencies = {
1970
1990
  ...app.devDependencies,
1971
1991
  ...ARCHETYPE_DEV_DEPENDENCIES[archetype],
@@ -1974,7 +1994,7 @@ function create(args) {
1974
1994
  };
1975
1995
  app.devDependencies = Object.fromEntries(Object.entries(app.devDependencies).sort(([a], [b]) => a.localeCompare(b)));
1976
1996
  writeJson(join(dir, "package.json"), app);
1977
- const holder = git(["config", "user.name"], existsSync(dir) ? dir : process.cwd());
1997
+ const holder = git$1(["config", "user.name"], existsSync(dir) ? dir : process.cwd());
1978
1998
  const moved = resolve(plan.file) === resolve(join(dir, LEGACY_PLAN));
1979
1999
  writePlan(dir, plan);
1980
2000
  writeAppFiles(dir, {
@@ -1987,7 +2007,13 @@ function create(args) {
1987
2007
  `lernapps create: the ${archetype} app ${name} in ${dir} (app-templates ${templates.version})`,
1988
2008
  ...guide.map((line) => `Content guide: ${line}`),
1989
2009
  ...moved ? [`Plan: moved ${LEGACY_PLAN} to ${relative(dir, plan.target)}`] : [],
1990
- `Next: ${existsSync(join(dir, ".git")) ? "" : "git init, "}npm install, then follow ${relative(dir, plan.target)}`,
2010
+ "Next, in this order (create installs and commits nothing):",
2011
+ ...[
2012
+ ...existsSync(join(dir, ".git")) ? [] : [`git init --initial-branch=${WORK_BRANCH}`],
2013
+ "npm install (the runtime and the git hooks)",
2014
+ "git add -A, then git commit (the first commit; the hooks check it)",
2015
+ `follow ${relative(dir, plan.target)}`
2016
+ ].map((step, index) => ` ${index + 1}. ${step}`),
1991
2017
  ""
1992
2018
  ].join("\n"));
1993
2019
  return 0;
@@ -1996,17 +2022,19 @@ function create(args) {
1996
2022
  /** The options of `lernapps init`, as node:util parseArgs takes them. */
1997
2023
  const INIT_OPTIONS = {
1998
2024
  addon: { type: "string" },
2025
+ tooling: { type: "string" },
1999
2026
  help: {
2000
2027
  type: "boolean",
2001
2028
  short: "h",
2002
2029
  default: false
2003
2030
  }
2004
2031
  };
2005
- const INIT_USAGE = `Usage: lernapps init <short name> [--addon <package>]
2032
+ const INIT_USAGE = `Usage: lernapps init <short name> [--addon <package>] [--tooling <spec>]
2006
2033
 
2007
2034
  Starts an app in the current folder, which must be empty except for .git. It needs git, Node and the npm registry; nothing
2008
2035
  else. In order:
2009
- git repository on the branch work, which the plan names (unless the folder is one already; no commit)
2036
+ git repository on the branch work, which the plan names; no commit. A repository without commits
2037
+ switches to work; one with commits stays, and init warns: git switch -c work
2010
2038
  package.json names the folder, depends on @lernapps/tooling (this version) and the add-on
2011
2039
  npm install installs them
2012
2040
  .vibe/plans/01-<short name>.md
@@ -2018,6 +2046,12 @@ Then follow the plan: it says what to do, and when to wait for the creator.
2018
2046
 
2019
2047
  Options:
2020
2048
  --addon <package> a package that extends the tooling, e.g. @lernapps/practice: its plan template and its skills
2049
+ --tooling <spec> for maintainers: the npm spec of @lernapps/tooling to install instead of this version, e.g.
2050
+ file:/path/to/lernapps-tooling-0.0.0.tgz or github:lernapps/tooling#<commit>; give the same
2051
+ to lernapps create. Without it, a tooling of version 0.0.0 (a local build, not on the
2052
+ registry) is packed with npm pack into a temporary folder and installed from that tarball
2053
+ when its folder is a git checkout (file:<tarball>), with its dependencies, as from the
2054
+ registry
2021
2055
  -h, --help print this help
2022
2056
  `;
2023
2057
  /** The options of `lernapps skills`. */
@@ -2044,25 +2078,57 @@ const SKILLS_BIN = () => {
2044
2078
  const bin = readJson(manifest)["bin"];
2045
2079
  return join(dirname(manifest), bin?.["skills"] ?? "bin/cli.mjs");
2046
2080
  };
2047
- /** The branch the plan works on (rule 8 of the plan template). */
2048
- const WORK_BRANCH = "work";
2049
- /**
2050
- * Makes the folder a git repository on the branch `work`, which the plan names, unless it is one already: `create` and
2051
- * the hooks need it, and the plan's first rule is to work there. Returns what it did, for the output; no commit.
2052
- */
2053
- function ensureRepository(dir) {
2054
- if (existsSync(join(dir, ".git"))) return void 0;
2055
- const result = spawnSync("git", [
2056
- "init",
2057
- "--quiet",
2058
- `--initial-branch=${WORK_BRANCH}`
2059
- ], {
2081
+ /** A git command in `dir`; a git that cannot run is an error of init. */
2082
+ function git(dir, args) {
2083
+ const result = spawnSync("git", args, {
2060
2084
  cwd: dir,
2061
2085
  encoding: "utf8"
2062
2086
  });
2063
2087
  if (result.error) throw new CreateError(`git cannot run: ${result.error.message}. Install git, then run init again`);
2064
- if (result.status !== 0) throw new CreateError(`git init failed: ${`${result.stdout}${result.stderr}`.trim()}`);
2065
- return `a git repository on the branch ${WORK_BRANCH}`;
2088
+ return {
2089
+ ok: result.status === 0,
2090
+ out: `${result.stdout}${result.stderr}`.trim()
2091
+ };
2092
+ }
2093
+ /**
2094
+ * Puts the folder on the branch `work`, which the plan names: `create` and the hooks need a repository, and the plan's
2095
+ * first rule is to work there. No repository yet → `git init` on `work`. A repository without commits (a fresh
2096
+ * `git init` on master or main, a clone of an empty GitHub repo) → its unborn branch becomes `work`: nothing is lost,
2097
+ * since there is nothing on it. A repository with commits on another branch is left as it is, with a warning that
2098
+ * names the command. No commit.
2099
+ */
2100
+ function ensureRepository(dir) {
2101
+ if (!existsSync(join(dir, ".git"))) {
2102
+ const result = git(dir, [
2103
+ "init",
2104
+ "--quiet",
2105
+ `--initial-branch=${WORK_BRANCH}`
2106
+ ]);
2107
+ if (!result.ok) throw new CreateError(`git init failed: ${result.out}`);
2108
+ return { done: `a git repository on the branch ${WORK_BRANCH}` };
2109
+ }
2110
+ const branch = git(dir, [
2111
+ "symbolic-ref",
2112
+ "--quiet",
2113
+ "--short",
2114
+ "HEAD"
2115
+ ]);
2116
+ if (branch.ok && branch.out === "work") return {};
2117
+ if (!git(dir, [
2118
+ "rev-parse",
2119
+ "--verify",
2120
+ "--quiet",
2121
+ "HEAD"
2122
+ ]).ok) {
2123
+ const result = git(dir, [
2124
+ "symbolic-ref",
2125
+ "HEAD",
2126
+ `refs/heads/${WORK_BRANCH}`
2127
+ ]);
2128
+ if (!result.ok) throw new CreateError(`git could not switch to the branch ${WORK_BRANCH}: ${result.out}`);
2129
+ return { done: `the repository had no commit yet: now on the branch ${WORK_BRANCH}` };
2130
+ }
2131
+ return { warning: `the repository has commits and is ${branch.ok ? `on the branch ${branch.out}` : "on no branch (detached HEAD)"}, not on ${WORK_BRANCH}, which the plan works on. Run next: git switch -c ${WORK_BRANCH}` };
2066
2132
  }
2067
2133
  /** The names of the skills of the archetypes, with the archetype each belongs to (lernapps-<archetype>[-…]). */
2068
2134
  function archetypeSkills() {
@@ -2101,6 +2167,37 @@ function syncSkills(dir, exclude) {
2101
2167
  ...filters.length > 0 ? ["--exclude", ...filters] : []
2102
2168
  ], (message) => new CreateError(message), dir);
2103
2169
  }
2170
+ /** The version of a build that is not on the registry: package.json of the repository says 0.0.0. */
2171
+ const LOCAL_VERSION = "0.0.0";
2172
+ /**
2173
+ * The tarball of the package in `root`, packed by npm pack (with its prepare script, so the build is current) into a
2174
+ * new temporary folder.
2175
+ */
2176
+ function packBuild(root) {
2177
+ const out = mkdtempSync(join(tmpdir(), "lernapps-tooling-"));
2178
+ run("npm", [
2179
+ "pack",
2180
+ "--silent",
2181
+ "--pack-destination",
2182
+ out
2183
+ ], (message) => new CreateError(message), root);
2184
+ const tarball = readdirSync(out).find((name) => name.endsWith(".tgz"));
2185
+ if (tarball === void 0) throw new CreateError(`npm pack in ${root} wrote no tarball into ${out}`);
2186
+ return join(out, tarball);
2187
+ }
2188
+ /**
2189
+ * The spec of @lernapps/tooling that init installs: the one given, else this version. A local build (version 0.0.0,
2190
+ * which the registry does not have) is packed when its folder is a git checkout and installed from the tarball, else
2191
+ * init says to name one with --tooling. Not the folder itself (file:<folder>): npm links it, so the app's
2192
+ * node_modules lacks the tooling's dependencies (Playwright for the end-to-end tests, @lernapps/app-templates with
2193
+ * the templates and their READMEs); from a tarball they are installed into the app, as from the registry.
2194
+ */
2195
+ function toolingSpec(given, version = ownVersion(), root = fromPackage(), pack = packBuild) {
2196
+ if (given !== void 0 && given !== "") return given;
2197
+ if (version !== LOCAL_VERSION) return version;
2198
+ if (existsSync(join(root, ".git"))) return `file:${pack(resolve(root))}`;
2199
+ throw new CreateError(`this @lernapps/tooling is a local build (version ${LOCAL_VERSION}), which the npm registry does not have: name the tooling to install with --tooling <spec>, e.g. file:/path/to/lernapps-tooling-${LOCAL_VERSION}.tgz (npm pack) or github:lernapps/tooling#<commit>, and give the same to lernapps create`);
2200
+ }
2104
2201
  function init(args) {
2105
2202
  const { values, positionals } = parseArgs({
2106
2203
  args,
@@ -2118,6 +2215,7 @@ function init(args) {
2118
2215
  const present = readdirSync(dir).filter((entry) => entry !== ".git");
2119
2216
  if (present.length > 0) throw new CreateError(`${dir} is not empty (${present.slice(0, 5).join(", ")}${present.length > 5 ? ", ..." : ""}): start in a new, empty folder`);
2120
2217
  const addon = values.addon;
2218
+ const tooling = toolingSpec(values.tooling);
2121
2219
  const repository = ensureRepository(dir);
2122
2220
  writeJson(join(dir, "package.json"), {
2123
2221
  name,
@@ -2131,7 +2229,7 @@ function init(args) {
2131
2229
  "--save-dev",
2132
2230
  "--no-audit",
2133
2231
  "--no-fund",
2134
- `${TOOLING}@${ownVersion()}`,
2232
+ `${TOOLING}@${tooling}`,
2135
2233
  ...addon ? [addon] : []
2136
2234
  ], fail);
2137
2235
  const source = (packageName, ...path) => join(dir, "node_modules", packageName, ...path);
@@ -2144,7 +2242,8 @@ function init(args) {
2144
2242
  mkdirSync(dirname(target), { recursive: true });
2145
2243
  writeFileSync(target, readFileSync(template));
2146
2244
  syncSkills(dir, archetypeSkills().map((skill) => skill.name));
2147
- process.stdout.write(`lernapps init: ${relative$1(dir, target)} from ${relativeTemplate(template, dir)}; the skills of the packages are installed${repository ? `; ${repository}` : ""}\nNext: read ${relative$1(dir, target)} completely and follow it\n`);
2245
+ if (repository.warning !== void 0) process.stderr.write(`lernapps init: warning: ${repository.warning}\n`);
2246
+ process.stdout.write(`lernapps init: ${relative$1(dir, target)} from ${relativeTemplate(template, dir)}; the skills of the packages are installed${repository.done ? `; ${repository.done}` : ""}\nNext: ${repository.warning ? `git switch -c ${WORK_BRANCH}, then ` : ""}read ${relative$1(dir, target)} completely and follow it\n`);
2148
2247
  return 0;
2149
2248
  }
2150
2249
  const relative$1 = (from, to) => to.startsWith(from) ? to.slice(from.length + 1) : to;
@@ -2182,13 +2281,16 @@ const UPDATE_USAGE = `Usage: lernapps update
2182
2281
  Moves the app in the current folder to the versions of this @lernapps/tooling, then runs npm install. npm update
2183
2282
  moves the app within its version ranges; this moves the ranges, and what npm does not know:
2184
2283
  @lernapps/app-templates to the version this tooling depends on: the runtime its tests ran with. A newer version
2185
- (e.g. from Renovate) is kept. A git commit, as apps once pinned, becomes a version
2284
+ (e.g. from Renovate) is kept. A git commit, as apps once pinned, becomes a version; while
2285
+ this tooling depends on a commit (an unreleased runtime), the app moves to that commit
2186
2286
  @lernapps/tooling to the version of this tooling (unchanged when run from the app)
2187
2287
  .github/workflows/ the actions of lernapps/tooling at the tag of that version
2188
2288
  .agents/skills/app-content/SKILL.md
2189
2289
  the content guide's skeleton, where the app has none yet (and its link for Claude Code)
2190
2290
  .vibe/plan.md the plan of an app made before the plans had a folder: moved to
2191
2291
  .vibe/plans/01-first-version.md, the first of its plans (one per piece of work)
2292
+ src/app.yaml of an explainer in format 1, when the runtime knows format 2: adds format: 2 and
2293
+ strand: none (the app is not on the learning map; its pages need no competencies)
2192
2294
  .agents/skills/lernapps-*, .claude/skills/lernapps-*, skills-lock.json
2193
2295
  replaced with the skills of this tooling, as npx skills add lays them out: those of
2194
2296
  every phase, and of the archetypes only those of the current plan's archetype; every
@@ -2222,9 +2324,11 @@ function lowest(range) {
2222
2324
  }
2223
2325
  /** Whether `a` is older than `b`. */
2224
2326
  const older = (a, b) => a[0] !== b[0] ? a[0] < b[0] : a[1] !== b[1] ? a[1] < b[1] : a[2] < b[2];
2225
- /** The next range of a dependency at `version`, and what the output says about it. */
2226
- function nextRange(name, current, version) {
2227
- const target = `^${version}`;
2327
+ /**
2328
+ * The next range of a dependency at `version` (target: ^ that version, or the spec given, e.g. a git commit of an
2329
+ * unreleased runtime), and what the output says about it.
2330
+ */
2331
+ function nextRange(name, current, version, target = `^${version}`) {
2228
2332
  if (current !== void 0 && /^(file:|link:|\.{0,2}\/)/.test(current)) return {
2229
2333
  spec: current,
2230
2334
  line: `${name}: kept ${current}, a local copy`
@@ -2235,7 +2339,7 @@ function nextRange(name, current, version) {
2235
2339
  spec: current,
2236
2340
  line: `${name}: unchanged (${current})`
2237
2341
  };
2238
- if (now !== void 0 && next !== void 0 && !older(now, next)) return {
2342
+ if (lowest(target) !== void 0 && now !== void 0 && next !== void 0 && !older(now, next)) return {
2239
2343
  spec: current ?? target,
2240
2344
  line: `${name}: kept ${current}, not moved to ${target}: it is not older`
2241
2345
  };
@@ -2269,6 +2373,30 @@ function migratePlan() {
2269
2373
  renameSync(LEGACY_PLAN, MIGRATED_PLAN);
2270
2374
  return [`plan: moved ${LEGACY_PLAN} to ${MIGRATED_PLAN}, unchanged: each piece of work has its own plan in .vibe/plans/`];
2271
2375
  }
2376
+ /** The explainer's app file, and the schema file that tells the runtime knows format 2 (learning map). */
2377
+ const EXPLAINER_APP = join("src", "app.yaml");
2378
+ const EXPLAINER_V2_SCHEMA = join("runtime", "explainer", "explainer.v2.schema.json");
2379
+ /**
2380
+ * Moves an explainer from format 1 to format 2 (the learning map): adds `format: 2` and `strand: none` to
2381
+ * src/app.yaml when the app has neither, and points its schema comment to explainer.v2.schema.json. `strand: none`
2382
+ * keeps the app off the map, so its pages need no competencies; the creator may name a strand later (skill
2383
+ * lernapps-content-research). Only where the runtime of this tooling has the v2 schema: an older runtime does not
2384
+ * know the fields. An app that is no explainer, or already has a `format` or a `strand`, stays as it is. What it did,
2385
+ * as lines of the output.
2386
+ */
2387
+ function migrateExplainer(templatesRoot) {
2388
+ if (!existsSync(EXPLAINER_APP) || !existsSync(join(templatesRoot, EXPLAINER_V2_SCHEMA))) return [];
2389
+ const text = readFileSync(EXPLAINER_APP, "utf8");
2390
+ const top = (key) => new RegExp(`^${key}:`, "m").test(text);
2391
+ if (!top("chapters") || top("format") || top("strand")) return [];
2392
+ const schema = /^(# yaml-language-server: \$schema=.*)explainer\.v1\.schema\.json$/m;
2393
+ const fields = "format: 2\nstrand: none\n";
2394
+ let next;
2395
+ if (schema.test(text)) next = text.replace(schema, (_all, head) => `${head}explainer.v2.schema.json\n${fields.trimEnd()}`);
2396
+ else next = fields + text;
2397
+ writeFileSync(EXPLAINER_APP, next);
2398
+ return [`explainer: ${EXPLAINER_APP} moved to format 2: added format: 2 and strand: none (not on the learning map; pages need no competencies). To join the map, pick a strand of https://lernapps.net/apps/map/ and give every page competencies`];
2399
+ }
2272
2400
  /**
2273
2401
  * The skills of this tooling, as it ships them (skills/<group>/<name>/), and as the app keeps them (flat, by name).
2274
2402
  * Every app has those of the groups process, tooling and content; of the group archetypes only those of its own
@@ -2477,7 +2605,7 @@ function update(args) {
2477
2605
  const notMoved = [];
2478
2606
  const runtime = devDependencies[RUNTIME];
2479
2607
  if (runtime !== void 0) {
2480
- const next = nextRange(RUNTIME, runtime, templates.version);
2608
+ const next = nextRange(RUNTIME, runtime, templates.version, runtimeSpec(templates.version));
2481
2609
  if (next.spec !== runtime) specs.push(`${RUNTIME}@${next.spec}`);
2482
2610
  lines.push(next.line);
2483
2611
  }
@@ -2507,6 +2635,8 @@ function update(args) {
2507
2635
  lines.push(...guide.map((line) => `content guide: ${line}`));
2508
2636
  const plan = migratePlan();
2509
2637
  lines.push(...plan);
2638
+ const explainer = migrateExplainer(templates.root);
2639
+ lines.push(...explainer);
2510
2640
  const skills = [...replaceSkills(), ...unignoreSkills()];
2511
2641
  lines.push(...skills);
2512
2642
  const architecture = updateArchitecture();
@@ -2524,6 +2654,7 @@ function update(args) {
2524
2654
  if (skillsMoved) files.push(SKILLS_LOCK);
2525
2655
  if (skills.some((line) => line.includes(".gitignore"))) files.push(".gitignore");
2526
2656
  if (architecture.lines.length > 0) files.push(`${ARCHITECTURE}/`);
2657
+ if (explainer.length > 0) files.push(EXPLAINER_APP);
2527
2658
  if (plan.some((line) => line.startsWith("plan: moved"))) files.push(".vibe/ (git add -A .vibe, to keep the move)");
2528
2659
  process.stdout.write(`Next: npx --no -- lernapps check, then commit ${files.slice(0, -1).join(", ")} and ${files.at(-1) ?? ""}\n`);
2529
2660
  return 0;
@@ -2659,6 +2790,13 @@ async function shootFigures(browser, base, page, width, file) {
2659
2790
  return count;
2660
2791
  });
2661
2792
  }
2793
+ /** Where an app's build is: dist/ (npx vp build, as lernapps check builds it) or _site/ (npm run build of the templates). */
2794
+ const BUILDS = ["dist", "_site"];
2795
+ const isBuild = (dir) => existsSync(dir) && statSync(dir).isDirectory() && existsSync(join(dir, "index.html"));
2796
+ /** The build to take without <dir>: of dist/ and _site/ the one with an index.html, the newer of both when both have one. */
2797
+ function defaultBundle() {
2798
+ return BUILDS.filter((dir) => isBuild(dir)).sort((a, b) => statSync(join(b, "index.html")).mtimeMs - statSync(join(a, "index.html")).mtimeMs)[0] ?? "dist";
2799
+ }
2662
2800
  /**
2663
2801
  * Writes the images and the index.html. Everything that can fail before is checked before the folder is touched, so a
2664
2802
  * failed run writes nothing. Returns the line to print.
@@ -2673,8 +2811,9 @@ async function screenshots(args) {
2673
2811
  if (positionals.length > 1) throw new UsageError(`one <dir> at most, not ${positionals.join(" ")}`);
2674
2812
  if (!/^\d+$/.test(values.width) || Number(values.width) < 200 || Number(values.width) > 4e3) throw new UsageError(`--width ${values.width}: a number of pixels from 200 to 4000`);
2675
2813
  const widths = [.../* @__PURE__ */ new Set([Number(values.width), ...values.desktop ? [DESKTOP] : []])];
2676
- const bundle = resolve(positionals[0] ?? "dist");
2677
- if (!existsSync(bundle) || !statSync(bundle).isDirectory() || !existsSync(join(bundle, "index.html"))) throw new UsageError(`${positionals[0] ?? "dist"}: not a directory with index.html (build the app first)`);
2814
+ const named = positionals[0] ?? defaultBundle();
2815
+ const bundle = resolve(named);
2816
+ if (!isBuild(bundle)) throw new UsageError(positionals[0] === void 0 ? `neither ${BUILDS.map((dir) => `${dir}/`).join(" nor ")} has an index.html (build the app first: npx vp build writes dist/, npm run build _site/)` : `${named}: not a directory with index.html (build the app first)`);
2678
2817
  const archetype = archetypeOf(readFileSync(join(bundle, "index.html"), "utf8"));
2679
2818
  if (archetype === void 0 || !SUPPORTED.includes(archetype)) throw new ScreenshotsError(`screenshots support explainer and quiz, not ${archetype === void 0 ? "this app" : `the ${archetype} archetype`}`);
2680
2819
  const out = resolve(values.out);
@@ -0,0 +1,124 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
+ import { parse } from "yaml";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ // check/explainer-map.ts
6
+ /** The app's description, relative to its repo. */
7
+ const APP_YAML = "src/app.yaml";
8
+ /** The longest wait for a map: a check without network says it could not check, it never hangs. */
9
+ const TIMEOUT = 1e4;
10
+ const read = (file) => {
11
+ try {
12
+ return parse(readFileSync(file, "utf8"));
13
+ } catch {
14
+ return;
15
+ }
16
+ };
17
+ const ids = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
18
+ /** The 1-based line of the first top-level `key:` in `text`, or undefined. */
19
+ function lineOf(text, ...keys) {
20
+ const index = text.split("\n").findIndex((line) => keys.some((key) => line.startsWith(`${key}:`)));
21
+ return index === -1 ? void 0 : index + 1;
22
+ }
23
+ /**
24
+ * The explainer in format 2 in the repo `project`, or undefined when there is none: no src/app.yaml, or one without
25
+ * `format: 2` (format 1, which has no strand), or one the build cannot read.
26
+ */
27
+ function readExplainer(project) {
28
+ const appFile = join(project, APP_YAML);
29
+ if (!existsSync(appFile)) return void 0;
30
+ const app = read(appFile);
31
+ if (app?.format !== 2 || typeof app.strand !== "string") return void 0;
32
+ const pages = [];
33
+ for (const chapter of ids(app.chapters)) {
34
+ const chapterYaml = read(join(project, "src", chapter, "chapter.yaml"));
35
+ for (const page of ids(chapterYaml?.pages)) {
36
+ const file = `src/${chapter}/${page}.yaml`;
37
+ if (!existsSync(join(project, file))) continue;
38
+ const text = readFileSync(join(project, file), "utf8");
39
+ const value = read(join(project, file));
40
+ const competencies = typeof value?.competency === "string" ? [value.competency] : ids(value?.competencies);
41
+ const recap = typeof value?.recap === "string" ? value.recap : void 0;
42
+ const line = lineOf(text, "competencies", "competency") ?? (recap === void 0 ? void 0 : lineOf(text, "recap"));
43
+ pages.push({
44
+ where: line === void 0 ? file : `${file}:${line}`,
45
+ competencies,
46
+ ...recap === void 0 ? {} : { recap },
47
+ text
48
+ });
49
+ }
50
+ }
51
+ const strandLine = lineOf(readFileSync(appFile, "utf8"), "strand");
52
+ return {
53
+ strand: app.strand,
54
+ strandAt: strandLine === void 0 ? APP_YAML : `${APP_YAML}:${strandLine}`,
55
+ pages
56
+ };
57
+ }
58
+ /** The strand of each competency of the map, by its id. */
59
+ const strandsOf = (map) => new Map(map.strands.flatMap((strand) => strand.competencies.map((competency) => [competency.id, strand.id])));
60
+ /** The address of a competency's page on the map, as a page links it: …/apps/map/<subject>/c/<id>/. */
61
+ const competencyPage = (subject, id) => `https://lernapps.net/apps/map/${subject}/c/${id}/`;
62
+ /**
63
+ * The address of the map of `subject`. For tests only, not for users: LERNAPPS_TEST_MAP_URL replaces the base
64
+ * (test/explainer-map.test.ts), an http(s) address, a `file:` URL or a local directory (absolute, or relative to the
65
+ * current directory), so that a sandbox without a local server can test against a map on disk
66
+ * (`<dir>/<subject>.json`). Not in the help.
67
+ */
68
+ function mapUrl(subject, env = process.env) {
69
+ let base = env["LERNAPPS_TEST_MAP_URL"] ?? "https://lernapps.net/apps/map/";
70
+ if (!/^(?:https?|file):/i.test(base)) base = pathToFileURL(isAbsolute(base) ? base : resolve(base)).href;
71
+ return `${base.endsWith("/") ? base : `${base}/`}${subject}.json`;
72
+ }
73
+ const isMap = (value) => typeof value === "object" && value !== null && Array.isArray(value.strands) && value.strands.every((strand) => typeof strand?.id === "string" && Array.isArray(strand.competencies));
74
+ /** The maps loaded in this run, by address: the checks of one run share one request per map. */
75
+ const loaded = /* @__PURE__ */ new Map();
76
+ /** The map at `url`, or why it could not be loaded: no network, a status other than 200, not a map. */
77
+ function loadMap(url) {
78
+ let pending = loaded.get(url);
79
+ if (pending === void 0) {
80
+ pending = fetchMap(url);
81
+ loaded.set(url, pending);
82
+ }
83
+ return pending;
84
+ }
85
+ async function fetchMap(url) {
86
+ if (url.startsWith("file:")) return readMap(url);
87
+ let response;
88
+ try {
89
+ response = await fetch(url, {
90
+ redirect: "follow",
91
+ signal: AbortSignal.timeout(TIMEOUT)
92
+ });
93
+ } catch (error) {
94
+ return { failed: `${url} could not be reached (${error instanceof Error ? error.message : String(error)})` };
95
+ }
96
+ if (!response.ok) {
97
+ await response.body?.cancel();
98
+ return { failed: response.status === 404 ? `${url} does not exist (404): the subject has no map yet, or it is not the subject's id` : `${url} answers ${response.status}` };
99
+ }
100
+ let value;
101
+ try {
102
+ value = await response.json();
103
+ } catch {
104
+ return { failed: `${url} is not JSON` };
105
+ }
106
+ return isMap(value) ? { map: value } : { failed: `${url} is not the map of a subject (no strands)` };
107
+ }
108
+ /** The map in a local file (LERNAPPS_TEST_MAP_URL with a `file:` URL or a directory), read as fetchMap reads one. */
109
+ function readMap(url) {
110
+ let text;
111
+ try {
112
+ text = readFileSync(fileURLToPath(url), "utf8");
113
+ } catch (error) {
114
+ return error.code === "ENOENT" ? { failed: `${url} does not exist: the subject has no map yet, or it is not the subject's id` } : { failed: `${url} could not be read (${error instanceof Error ? error.message : String(error)})` };
115
+ }
116
+ let value;
117
+ try {
118
+ value = JSON.parse(text);
119
+ } catch {
120
+ return { failed: `${url} is not JSON` };
121
+ }
122
+ return isMap(value) ? { map: value } : { failed: `${url} is not the map of a subject (no strands)` };
123
+ }
124
+ export { strandsOf as a, readExplainer as i, loadMap as n, mapUrl as r, competencyPage as t };
@@ -39,7 +39,8 @@ The READMEs are files of the package `@lernapps/app-templates`, which `init` ins
39
39
 
40
40
  - Choose after the interview topics "Goal" and "Learners", from what learners do. Read the patterns of the subject
41
41
  first (skill `lernapps-exercise-patterns`): how learners practise points to the archetype.
42
- - Before you choose, read the README of each archetype in question, in full. Tell the creator in plain words what the
42
+ - Before you choose, read the README of every archetype you consider, in full. One you rule out from the kind of
43
+ practice alone needs no README: say in one sentence why it does not fit. Tell the creator in plain words what the
43
44
  chosen one can and cannot do, before you ask about content, amount and look.
44
45
  - A few background sections on the start page keep an app `interactive`.
45
46
  - Explanation pages with one interactive exercise → `explainer` with a component.