@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.
- package/dist/check/rules/competencies-on-map.mjs +85 -0
- package/dist/check/rules/one-competency-per-page.mjs +18 -0
- package/dist/check/rules/prerequisites-linked.mjs +38 -0
- package/dist/cli.mjs +191 -52
- package/dist/explainer-map-DW4XCcY0.mjs +124 -0
- package/dist/skills/lernapps-app/SKILL.md +2 -1
- package/dist/skills/lernapps-cli/SKILL.md +13 -4
- package/dist/skills/lernapps-content-questions/SKILL.md +2 -0
- package/dist/skills/lernapps-content-questions/references/question-types.md +2 -1
- package/dist/skills/lernapps-content-research/SKILL.md +41 -5
- package/dist/skills/lernapps-exercise-patterns/references/mathematics.md +25 -1
- package/dist/skills/lernapps-exercise-patterns/references/physics.md +1 -0
- package/dist/skills/lernapps-explainer/SKILL.md +10 -4
- package/dist/skills/lernapps-explainer/references/blocks.md +7 -3
- package/dist/skills/lernapps-explainer/references/exercises.md +6 -0
- package/dist/skills/lernapps-explainer/references/rules.md +29 -0
- package/dist/skills/lernapps-explainer/references/structure.md +62 -0
- package/dist/skills/lernapps-explainer-design/SKILL.md +46 -2
- package/dist/skills/lernapps-explainer-design/references/rules.md +1 -0
- package/dist/skills/lernapps-explainer-self-assessment/SKILL.md +1 -0
- package/dist/skills/lernapps-listing/SKILL.md +9 -0
- package/generator/create.ts +39 -10
- package/generator/init.ts +89 -19
- package/generator/update.ts +53 -8
- package/guidance/increment-template.md +13 -5
- package/guidance/plan-template.md +25 -11
- package/guidance/workflow-rules.md +3 -1
- package/package.json +2 -2
- package/skills/archetypes/lernapps-explainer/SKILL.md +10 -4
- package/skills/archetypes/lernapps-explainer/references/blocks.md +7 -3
- package/skills/archetypes/lernapps-explainer/references/exercises.md +6 -0
- package/skills/archetypes/lernapps-explainer/references/rules.md +29 -0
- package/skills/archetypes/lernapps-explainer/references/structure.md +62 -0
- package/skills/archetypes/lernapps-explainer-design/SKILL.md +46 -2
- package/skills/archetypes/lernapps-explainer-design/references/rules.md +1 -0
- package/skills/archetypes/lernapps-explainer-self-assessment/SKILL.md +1 -0
- package/skills/content/lernapps-content-questions/SKILL.md +2 -0
- package/skills/content/lernapps-content-questions/references/question-types.md +2 -1
- package/skills/content/lernapps-content-research/SKILL.md +41 -5
- package/skills/content/lernapps-exercise-patterns/references/mathematics.md +25 -1
- package/skills/content/lernapps-exercise-patterns/references/physics.md +1 -0
- package/skills/process/lernapps-app/SKILL.md +2 -1
- package/skills/process/lernapps-listing/SKILL.md +9 -0
- 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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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/,
|
|
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
|
|
1055
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
1389
|
-
return git$
|
|
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$
|
|
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
|
-
|
|
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:
|
|
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]:
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
2048
|
-
|
|
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
|
-
|
|
2065
|
-
|
|
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}@${
|
|
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.
|
|
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
|
-
/**
|
|
2226
|
-
|
|
2227
|
-
|
|
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
|
|
2677
|
-
|
|
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
|
|
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.
|