@lernapps/tooling 0.0.1 → 0.1.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 (126) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +67 -2
  3. package/archetypes/shared/a11y.ts +38 -0
  4. package/archetypes/shared/hooks/pre-commit +22 -0
  5. package/archetypes/shared/hooks/pre-push +4 -0
  6. package/archetypes/shared/i18n.ts +25 -0
  7. package/archetypes/shared/install-hooks.ts +63 -0
  8. package/archetypes/shared/playwright.ts +54 -0
  9. package/archetypes/shared/preset.ts +81 -0
  10. package/archetypes/shared/storage.ts +167 -0
  11. package/archetypes/shared/tsconfig.json +27 -0
  12. package/check/validation-report.v1.schema.json +116 -0
  13. package/dist/a11y.mjs +30 -0
  14. package/dist/check/rules/accessible.mjs +18 -0
  15. package/dist/check/rules/app-content-guide.mjs +69 -0
  16. package/dist/check/rules/dependency-licence.mjs +82 -0
  17. package/dist/check/rules/entry-fitness-matches.mjs +22 -0
  18. package/dist/check/rules/entry-links-resolve.mjs +44 -0
  19. package/dist/check/rules/explainer-readable-without-js.mjs +66 -0
  20. package/dist/check/rules/links-resolve.mjs +10 -0
  21. package/dist/check/rules/lockfile-matches.mjs +57 -0
  22. package/dist/check/rules/no-request-before-click.mjs +17 -0
  23. package/dist/check/rules/no-tracking.mjs +17 -0
  24. package/dist/check/rules/own-data-only.mjs +66 -0
  25. package/dist/check/rules/pages-reachable.mjs +25 -0
  26. package/dist/check/rules/usable-at-360px.mjs +19 -0
  27. package/dist/check-cv4I6CCH.mjs +28 -0
  28. package/dist/cli.mjs +3118 -0
  29. package/dist/i18n.mjs +11 -0
  30. package/dist/lint.mjs +528 -0
  31. package/dist/plan-DWKayEnY.mjs +75 -0
  32. package/dist/playwright.mjs +47 -0
  33. package/dist/preset.mjs +60 -0
  34. package/dist/skills/lernapps-app/SKILL.md +49 -0
  35. package/dist/skills/lernapps-cli/SKILL.md +156 -0
  36. package/dist/skills/lernapps-cli/references/platform-rules.md +258 -0
  37. package/dist/skills/lernapps-commit/SKILL.md +39 -0
  38. package/dist/skills/lernapps-content-growth/SKILL.md +33 -0
  39. package/dist/skills/lernapps-content-questions/SKILL.md +133 -0
  40. package/dist/skills/lernapps-content-questions/references/question-types.md +69 -0
  41. package/dist/skills/lernapps-content-research/SKILL.md +50 -0
  42. package/dist/skills/lernapps-content-research/references/sources.md +16 -0
  43. package/dist/skills/lernapps-exercise-patterns/SKILL.md +74 -0
  44. package/dist/skills/lernapps-exercise-patterns/references/biology-geography.md +13 -0
  45. package/dist/skills/lernapps-exercise-patterns/references/chemistry.md +16 -0
  46. package/dist/skills/lernapps-exercise-patterns/references/computer-science.md +13 -0
  47. package/dist/skills/lernapps-exercise-patterns/references/figures.md +10 -0
  48. package/dist/skills/lernapps-exercise-patterns/references/history.md +13 -0
  49. package/dist/skills/lernapps-exercise-patterns/references/mathematics.md +19 -0
  50. package/dist/skills/lernapps-exercise-patterns/references/page-structures.md +8 -0
  51. package/dist/skills/lernapps-exercise-patterns/references/physics.md +18 -0
  52. package/dist/skills/lernapps-exercise-patterns/references/success.md +28 -0
  53. package/dist/skills/lernapps-explainer/SKILL.md +33 -0
  54. package/dist/skills/lernapps-explainer/references/blocks.md +83 -0
  55. package/dist/skills/lernapps-explainer/references/components.md +66 -0
  56. package/dist/skills/lernapps-explainer/references/exercises.md +115 -0
  57. package/dist/skills/lernapps-explainer/references/rules.md +285 -0
  58. package/dist/skills/lernapps-explainer/references/runtime.md +79 -0
  59. package/dist/skills/lernapps-explainer/references/structure.md +24 -0
  60. package/dist/skills/lernapps-explainer-design/SKILL.md +57 -0
  61. package/dist/skills/lernapps-explainer-design/references/rules.md +75 -0
  62. package/dist/skills/lernapps-explainer-self-assessment/SKILL.md +93 -0
  63. package/dist/skills/lernapps-github/SKILL.md +102 -0
  64. package/dist/skills/lernapps-interactive/SKILL.md +141 -0
  65. package/dist/skills/lernapps-interactive/references/frame.md +30 -0
  66. package/dist/skills/lernapps-interactive-design/SKILL.md +58 -0
  67. package/dist/skills/lernapps-listing/SKILL.md +44 -0
  68. package/dist/skills/lernapps-listing/references/keeping-updated.md +33 -0
  69. package/dist/skills/lernapps-quiz/SKILL.md +100 -0
  70. package/dist/skills/lernapps-quiz-design/SKILL.md +48 -0
  71. package/dist/status-CXoQBgfU.mjs +76 -0
  72. package/dist/storage.mjs +113 -0
  73. package/generator/app/.agents/skills/app-content/SKILL.md +63 -0
  74. package/generator/app/.github/ISSUE_TEMPLATE/inhaltsfehler.yml +25 -0
  75. package/generator/app/.github/workflows/pages.yml +61 -0
  76. package/generator/app/LICENSE +21 -0
  77. package/generator/app/renovate.json +4 -0
  78. package/generator/architecture.ts +212 -0
  79. package/generator/create.ts +462 -0
  80. package/generator/init.ts +186 -0
  81. package/generator/update.ts +429 -0
  82. package/guidance/AGENTS.md +26 -0
  83. package/guidance/increment-template.md +194 -0
  84. package/guidance/plan-front-matter.v1.schema.json +36 -0
  85. package/guidance/plan-template.md +233 -0
  86. package/guidance/subagent-brief.md +25 -0
  87. package/guidance/workflow-rules.md +109 -0
  88. package/package.json +133 -4
  89. package/review/verdict.v1.schema.json +134 -0
  90. package/skills/archetypes/lernapps-explainer/SKILL.md +33 -0
  91. package/skills/archetypes/lernapps-explainer/references/blocks.md +83 -0
  92. package/skills/archetypes/lernapps-explainer/references/components.md +66 -0
  93. package/skills/archetypes/lernapps-explainer/references/exercises.md +115 -0
  94. package/skills/archetypes/lernapps-explainer/references/rules.md +285 -0
  95. package/skills/archetypes/lernapps-explainer/references/runtime.md +79 -0
  96. package/skills/archetypes/lernapps-explainer/references/structure.md +24 -0
  97. package/skills/archetypes/lernapps-explainer-design/SKILL.md +57 -0
  98. package/skills/archetypes/lernapps-explainer-design/references/rules.md +75 -0
  99. package/skills/archetypes/lernapps-explainer-self-assessment/SKILL.md +93 -0
  100. package/skills/archetypes/lernapps-interactive/SKILL.md +141 -0
  101. package/skills/archetypes/lernapps-interactive/references/frame.md +30 -0
  102. package/skills/archetypes/lernapps-interactive-design/SKILL.md +58 -0
  103. package/skills/archetypes/lernapps-quiz/SKILL.md +100 -0
  104. package/skills/archetypes/lernapps-quiz-design/SKILL.md +48 -0
  105. package/skills/content/lernapps-content-questions/SKILL.md +133 -0
  106. package/skills/content/lernapps-content-questions/references/question-types.md +69 -0
  107. package/skills/content/lernapps-content-research/SKILL.md +50 -0
  108. package/skills/content/lernapps-content-research/references/sources.md +16 -0
  109. package/skills/content/lernapps-exercise-patterns/SKILL.md +74 -0
  110. package/skills/content/lernapps-exercise-patterns/references/biology-geography.md +13 -0
  111. package/skills/content/lernapps-exercise-patterns/references/chemistry.md +16 -0
  112. package/skills/content/lernapps-exercise-patterns/references/computer-science.md +13 -0
  113. package/skills/content/lernapps-exercise-patterns/references/figures.md +10 -0
  114. package/skills/content/lernapps-exercise-patterns/references/history.md +13 -0
  115. package/skills/content/lernapps-exercise-patterns/references/mathematics.md +19 -0
  116. package/skills/content/lernapps-exercise-patterns/references/page-structures.md +8 -0
  117. package/skills/content/lernapps-exercise-patterns/references/physics.md +18 -0
  118. package/skills/content/lernapps-exercise-patterns/references/success.md +28 -0
  119. package/skills/process/lernapps-app/SKILL.md +49 -0
  120. package/skills/process/lernapps-commit/SKILL.md +39 -0
  121. package/skills/process/lernapps-content-growth/SKILL.md +33 -0
  122. package/skills/process/lernapps-github/SKILL.md +102 -0
  123. package/skills/process/lernapps-listing/SKILL.md +44 -0
  124. package/skills/process/lernapps-listing/references/keeping-updated.md +33 -0
  125. package/skills/tooling/lernapps-cli/SKILL.md +156 -0
  126. package/skills/tooling/lernapps-cli/references/platform-rules.md +258 -0
@@ -0,0 +1,116 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://lernapps.net/tooling/schemas/validation-report.v1.schema.json",
4
+ "title": "Validation report of lernapps check, version 1",
5
+ "description": "What `lernapps check` checked and found, as YAML: on standard output when a check fails, and always in the file of `--report <file>`. The same input gives the same report. A breaking change gets a new version of this schema.",
6
+ "type": "object",
7
+ "properties": {
8
+ "checked": {
9
+ "description": "What was checked: the commit of a repo (or its directory outside git), a bundle directory, or a URL and the time it was checked.",
10
+ "oneOf": [
11
+ {
12
+ "type": "object",
13
+ "properties": { "commit": { "type": "string", "pattern": "^[0-9a-f]{40}$" } },
14
+ "required": ["commit"],
15
+ "additionalProperties": false
16
+ },
17
+ {
18
+ "type": "object",
19
+ "properties": { "directory": { "type": "string", "minLength": 1 } },
20
+ "required": ["directory"],
21
+ "additionalProperties": false
22
+ },
23
+ {
24
+ "type": "object",
25
+ "properties": { "bundle": { "type": "string", "minLength": 1 } },
26
+ "required": ["bundle"],
27
+ "additionalProperties": false
28
+ },
29
+ {
30
+ "type": "object",
31
+ "properties": {
32
+ "url": { "type": "string", "minLength": 1 },
33
+ "at": { "type": "string", "description": "ISO 8601 time of the check" }
34
+ },
35
+ "required": ["url", "at"],
36
+ "additionalProperties": false
37
+ }
38
+ ]
39
+ },
40
+ "parts": {
41
+ "description": "In a repo: the parts that ran, pre-commit (format, lint, types) and pre-push (unit tests, build, checks of the built app, end-to-end tests).",
42
+ "type": "array",
43
+ "items": { "enum": ["pre-commit", "pre-push"] },
44
+ "uniqueItems": true
45
+ },
46
+ "rules": {
47
+ "description": "The ids of the rules checked: every check that found something to check, checks-pass for the toolchain steps, and app-checkable when a built app or a URL could not be checked at all.",
48
+ "type": "array",
49
+ "items": { "$ref": "#/$defs/id" },
50
+ "uniqueItems": true
51
+ },
52
+ "findings": {
53
+ "description": "Every finding, sorted by rule id and place. Exit code 1 when one has severity error.",
54
+ "type": "array",
55
+ "items": {
56
+ "type": "object",
57
+ "properties": {
58
+ "rule": { "$ref": "#/$defs/id" },
59
+ "severity": {
60
+ "enum": ["error", "warning", "hint"],
61
+ "description": "The rule's severity; hint also for what a check could not decide, such as a link whose server is rate limited (429). Only error fails"
62
+ },
63
+ "where": {
64
+ "type": "string",
65
+ "minLength": 1,
66
+ "description": "File and line, page and element, or the command"
67
+ },
68
+ "found": { "type": "string", "minLength": 1, "description": "What was found" },
69
+ "fix": { "type": "string", "minLength": 1, "description": "How to fix it" },
70
+ "link": {
71
+ "type": "string",
72
+ "pattern": "^https://lernapps\\.net/tooling/rules/#[a-z][a-z0-9]*(-[a-z0-9]+)*$",
73
+ "description": "The rule's entry on the rule page"
74
+ }
75
+ },
76
+ "required": ["rule", "severity", "where", "found", "fix", "link"],
77
+ "additionalProperties": false
78
+ }
79
+ },
80
+ "suppressions": {
81
+ "description": "Warnings suppressed in place, each with its reason.",
82
+ "type": "array",
83
+ "items": {
84
+ "type": "object",
85
+ "properties": {
86
+ "rule": { "$ref": "#/$defs/id" },
87
+ "where": { "type": "string", "minLength": 1 },
88
+ "reason": { "type": "string", "minLength": 1 }
89
+ },
90
+ "required": ["rule", "where", "reason"],
91
+ "additionalProperties": false
92
+ }
93
+ },
94
+ "fitness": {
95
+ "description": "The fitness values measured on the built app, without a click, in the words of the catalog entry.",
96
+ "type": "object",
97
+ "properties": {
98
+ "storage": {
99
+ "enum": ["none", "device"],
100
+ "description": "none: nothing stored; device: only in the browser, also what the app reads on loading and keeps after an input"
101
+ },
102
+ "thirdParty": {
103
+ "enum": ["none", "before-click"],
104
+ "description": "none: no request to another host before a click; before-click: requests that keep the app from being listed"
105
+ }
106
+ },
107
+ "required": ["storage", "thirdParty"],
108
+ "additionalProperties": false
109
+ }
110
+ },
111
+ "required": ["checked", "rules", "findings", "suppressions"],
112
+ "additionalProperties": false,
113
+ "$defs": {
114
+ "id": { "type": "string", "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$" }
115
+ }
116
+ }
package/dist/a11y.mjs ADDED
@@ -0,0 +1,30 @@
1
+ // archetypes/shared/a11y.ts
2
+ const regions = /* @__PURE__ */ new Map();
3
+ /** A visually hidden live region, created once per politeness. */
4
+ function region(politeness) {
5
+ const existing = regions.get(politeness);
6
+ if (existing?.isConnected) return existing;
7
+ const element = document.createElement("div");
8
+ element.setAttribute("aria-live", politeness);
9
+ element.setAttribute("aria-atomic", "true");
10
+ element.style.cssText = "position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0";
11
+ document.body.append(element);
12
+ regions.set(politeness, element);
13
+ return element;
14
+ }
15
+ /** Says `text` to screen readers, e.g. the feedback after an answer; "assertive" interrupts, for errors only. */
16
+ function announce(text, politeness = "polite") {
17
+ const live = region(politeness);
18
+ live.textContent = "";
19
+ setTimeout(() => {
20
+ live.textContent = text;
21
+ }, 50);
22
+ }
23
+ /** Moves the keyboard focus to `element`, e.g. the heading of the next task, making it focusable if needed. */
24
+ function moveFocus(element) {
25
+ if (!element.matches("a[href], button, input, select, textarea, [tabindex]")) element.tabIndex = -1;
26
+ element.focus();
27
+ }
28
+ /** Whether the learner asked for less motion: animate only when this is false. */
29
+ const prefersReducedMotion = () => globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
30
+ export { announce, moveFocus, prefersReducedMotion };
@@ -0,0 +1,18 @@
1
+ import { n as declaredLimit } from "../../check-cv4I6CCH.mjs";
2
+ // check/rules/accessible.ts
3
+ var accessible_default = {
4
+ id: "accessible",
5
+ url: "https://lernapps.net/tooling/rules/#accessible",
6
+ description: "axe-core finds no violation of WCAG 2.1 A or AA on any page of the built app; warnings when the entry declares the app's limits.",
7
+ severity: "error",
8
+ run: (context) => {
9
+ const severity = declaredLimit(context);
10
+ return context.app?.pages.flatMap(({ page, axe }) => axe.map(({ id, help, helpUrl, targets }) => ({
11
+ ...severity ? { severity } : {},
12
+ where: `${page} ${targets[0] ?? ""}${targets.length > 1 ? ` (and ${targets.length - 1} more)` : ""}`.trim(),
13
+ found: `${help} (axe ${id})`,
14
+ fix: `${help}: ${helpUrl}`
15
+ })));
16
+ }
17
+ };
18
+ export { accessible_default as default };
@@ -0,0 +1,69 @@
1
+ import { c as planFrontMatter, s as currentPlan } from "../../plan-DWKayEnY.mjs";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ // check/rules/app-content-guide.ts
5
+ /** The guide, relative to the app's root. */
6
+ const SKILL = ".agents/skills/app-content/SKILL.md";
7
+ /** Its fixed level-2 headings, in order: the skeleton has them, and a follow-up agent finds each part by them. */
8
+ const HEADINGS = [
9
+ "Curriculum",
10
+ "Competencies",
11
+ "Content structure",
12
+ "Terminology",
13
+ "Exercise patterns",
14
+ "Figures",
15
+ "Sources",
16
+ "Adding a chapter or round",
17
+ "Rules learned from mistakes"
18
+ ];
19
+ /** A placeholder of the skeleton: an HTML comment that starts with "FILL IN:". */
20
+ const PLACEHOLDER = "<!-- FILL IN:";
21
+ const RUNTIME = "@lernapps/app-templates";
22
+ /** The `phase` of the current plan's front matter ("" when it has none); undefined when there is no plan. */
23
+ function phase(project) {
24
+ const plan = currentPlan(project);
25
+ if (plan === void 0) return void 0;
26
+ const value = planFrontMatter(join(project, plan))["phase"];
27
+ return typeof value === "string" ? value : "";
28
+ }
29
+ var app_content_guide_default = {
30
+ id: "app-content-guide",
31
+ url: "https://lernapps.net/tooling/rules/#app-content-guide",
32
+ description: "The app's content guide .agents/skills/app-content/SKILL.md exists with its fixed headings, and has no placeholder left once the plan is in Commit.",
33
+ severity: "error",
34
+ run: ({ project }) => {
35
+ if (project === void 0 || !existsSync(join(project, "package.json"))) return void 0;
36
+ const manifest = JSON.parse(readFileSync(join(project, "package.json"), "utf8"));
37
+ const fromTemplate = {
38
+ ...manifest.dependencies,
39
+ ...manifest.devDependencies
40
+ }[RUNTIME] !== void 0;
41
+ const current = phase(project);
42
+ if (!fromTemplate || current === void 0) return void 0;
43
+ const file = join(project, SKILL);
44
+ if (!existsSync(file)) return [{
45
+ where: SKILL,
46
+ found: "the app has no content guide",
47
+ fix: "Add the skeleton with npx --yes @lernapps/tooling@latest update, then fill it in from the plan as its comments say"
48
+ }];
49
+ const lines = readFileSync(file, "utf8").split("\n");
50
+ const problems = HEADINGS.filter((heading) => !lines.includes(`## ${heading}`)).map((heading) => ({
51
+ where: SKILL,
52
+ found: `the heading "## ${heading}" is missing`,
53
+ fix: `Keep the fixed headings of the skeleton: ${HEADINGS.map((name) => `"## ${name}"`).join(", ")}`
54
+ }));
55
+ if (current !== "commit") return problems;
56
+ let section = "";
57
+ lines.forEach((line, index) => {
58
+ if (line.startsWith("## ")) section = line;
59
+ if (!line.includes(PLACEHOLDER)) return;
60
+ problems.push({
61
+ where: `${SKILL}:${index + 1}${section === "" ? "" : ` (${section})`}`,
62
+ found: "a placeholder is left, but the app has content (the plan is in Commit)",
63
+ fix: "Replace it with precise, concrete instructions for this app, from the confirmed plan and what was built: name files, ids, data files and an example"
64
+ });
65
+ });
66
+ return problems;
67
+ }
68
+ };
69
+ export { HEADINGS, SKILL, app_content_guide_default as default };
@@ -0,0 +1,82 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, relative } from "node:path";
3
+ // check/rules/dependency-licence.ts
4
+ /** Licences that allow the app to be shared freely, with or without changes (SPDX identifiers). */
5
+ const ALLOWED = /* @__PURE__ */ new Set([
6
+ "0BSD",
7
+ "Apache-2.0",
8
+ "BlueOak-1.0.0",
9
+ "BSD-2-Clause",
10
+ "BSD-3-Clause",
11
+ "CC-BY-4.0",
12
+ "CC0-1.0",
13
+ "ISC",
14
+ "MIT",
15
+ "MIT-0",
16
+ "MPL-2.0",
17
+ "Python-2.0",
18
+ "Unlicense",
19
+ "Zlib"
20
+ ]);
21
+ /** An SPDX expression is allowed when one of its alternatives (OR) has only allowed parts (AND). */
22
+ const allowed = (expression) => expression.replace(/[()]/g, " ").split(/\s+OR\s+/).some((alternative) => alternative.split(/\s+AND\s+/).every((part) => ALLOWED.has(part.trim())));
23
+ const read = (file) => JSON.parse(readFileSync(file, "utf8"));
24
+ function licenceOf(manifest) {
25
+ const { license, licenses } = manifest;
26
+ if (typeof license === "string") return license;
27
+ if (typeof license === "object" && license !== null && "type" in license) return String(license.type);
28
+ if (Array.isArray(licenses)) return licenses.map((entry) => String(entry.type)).join(" OR ");
29
+ return "";
30
+ }
31
+ /** Where Node would find package `name` required from `from`: the nearest node_modules up to the project. */
32
+ function locate(name, from, project) {
33
+ for (let dir = from;; dir = dirname(dir)) {
34
+ const candidate = join(dir, "node_modules", name, "package.json");
35
+ if (existsSync(candidate)) return candidate;
36
+ if (dir === project || dirname(dir) === dir) return void 0;
37
+ }
38
+ }
39
+ var dependency_licence_default = {
40
+ id: "dependency-licence",
41
+ url: "https://lernapps.net/tooling/rules/#dependency-licence",
42
+ description: "Every runtime dependency of the app has a licence from the allowlist.",
43
+ severity: "error",
44
+ run: ({ project }) => {
45
+ if (project === void 0 || !existsSync(join(project, "package.json"))) return void 0;
46
+ const problems = [];
47
+ const seen = /* @__PURE__ */ new Set();
48
+ const walk = (manifest, dir) => {
49
+ const optional = manifest.optionalDependencies ?? {};
50
+ for (const name of Object.keys({
51
+ ...manifest.dependencies,
52
+ ...optional
53
+ }).sort()) {
54
+ const file = locate(name, dir, project);
55
+ if (file === void 0) {
56
+ if (!(name in optional) && !seen.has(name)) {
57
+ seen.add(name);
58
+ problems.push({
59
+ where: relative(project, join(dir, "package.json")) || "package.json",
60
+ found: `${name} is not installed, so its licence cannot be read`,
61
+ fix: "Run npm ci, then check again"
62
+ });
63
+ }
64
+ continue;
65
+ }
66
+ if (seen.has(file)) continue;
67
+ seen.add(file);
68
+ const dependency = read(file);
69
+ const licence = licenceOf(dependency);
70
+ if (!allowed(licence)) problems.push({
71
+ where: relative(project, file),
72
+ found: `${dependency.name ?? name}@${dependency.version ?? "?"} has ${licence === "" ? "no licence" : `the licence ${licence}`}, which is not on the allowlist`,
73
+ fix: `Replace it with a package under one of ${[...ALLOWED].join(", ")}`
74
+ });
75
+ walk(dependency, dirname(file));
76
+ }
77
+ };
78
+ walk(read(join(project, "package.json")), project);
79
+ return problems;
80
+ }
81
+ };
82
+ export { dependency_licence_default as default };
@@ -0,0 +1,22 @@
1
+ // check/rules/entry-fitness-matches.ts
2
+ var entry_fitness_matches_default = {
3
+ id: "entry-fitness-matches",
4
+ url: "https://lernapps.net/tooling/rules/#entry-fitness-matches",
5
+ description: "The fitness values of the catalog entry match the values measured on the app.",
6
+ severity: "error",
7
+ run: ({ entry, app }) => {
8
+ if (!entry || !app) return void 0;
9
+ if (entry.value.fitness?.storage !== "none" || app.fitness.storage === "none") return [];
10
+ const stored = [...new Set(app.pages.flatMap((page) => [
11
+ ...page.storage,
12
+ ...page.cookies,
13
+ ...page.access.map((access) => `${access.area} ${access.name}`)
14
+ ]))];
15
+ return [{
16
+ where: `${entry.file}: fitness.storage`,
17
+ found: `the entry says storage: none, but the app stores on the device (${stored.join(", ")})`,
18
+ fix: "Set fitness.storage to device in the entry, or let the app store nothing"
19
+ }];
20
+ }
21
+ };
22
+ export { entry_fitness_matches_default as default };
@@ -0,0 +1,44 @@
1
+ import { n as linkStatus, t as linkProblem } from "../../status-CXoQBgfU.mjs";
2
+ // check/rules/entry-links-resolve.ts
3
+ /** The section a link names: its fragment, decoded; "" when it names none. */
4
+ function sectionOf(link) {
5
+ const hash = new URL(link).hash.slice(1);
6
+ try {
7
+ return decodeURIComponent(hash);
8
+ } catch {
9
+ return hash;
10
+ }
11
+ }
12
+ var entry_links_resolve_default = {
13
+ id: "entry-links-resolve",
14
+ url: "https://lernapps.net/tooling/rules/#entry-links-resolve",
15
+ description: "The app's URL and every topic link of its catalog entry resolve; a topic's #<section> is an element id of its page.",
16
+ severity: "error",
17
+ run: async ({ entry }) => {
18
+ if (!entry) return void 0;
19
+ const { url, topics = [] } = entry.value;
20
+ const links = [{
21
+ where: `${entry.file}: url`,
22
+ url,
23
+ section: ""
24
+ }, ...topics.map((topic, index) => {
25
+ const link = new URL(topic.path, url).href;
26
+ return {
27
+ where: `${entry.file}: topics[${index}].path ${topic.path}`,
28
+ url: link,
29
+ section: sectionOf(link)
30
+ };
31
+ })];
32
+ return (await Promise.all(links.map(async ({ where, url: link, section }) => {
33
+ const { status, ids } = await linkStatus(link, { ids: section !== "" });
34
+ const broken = linkProblem(where, link, status, "Point the topic's path (relative to url) to a page of the app, or correct the app's url");
35
+ if (broken !== void 0 || section === "" || ids?.has(section)) return broken;
36
+ return {
37
+ where,
38
+ found: `${link}: the page has no element with the id "${section}", so the link does not lead to its section`,
39
+ fix: "Point the topic's #<section> to the id of a section of that page (#<section> or #<section>/<sub>, from the content's ids), or leave the anchor out to link the page"
40
+ };
41
+ }))).filter((problem) => problem !== void 0);
42
+ }
43
+ };
44
+ export { entry_links_resolve_default as default };
@@ -0,0 +1,66 @@
1
+ // check/rules/explainer-readable-without-js.ts
2
+ /** For what the content cannot cause: the runtime writes it into the page, visible without JavaScript. */
3
+ const RUNTIME_BUG = `The explainer runtime writes it into the page, visible without JavaScript: build with it (vp build), change no page after the build, and add no script or style that hides content. If that is so, this is a bug of the runtime: open an issue at https://github.com/lernapps/app-templates/issues with this report`;
4
+ const WHAT = {
5
+ heading: "the heading",
6
+ formula: "the formula",
7
+ figure: "the figure"
8
+ };
9
+ var explainer_readable_without_js_default = {
10
+ id: "explainer-readable-without-js",
11
+ url: "https://lernapps.net/tooling/rules/#explainer-readable-without-js",
12
+ description: "Each explainer page shows its headings, formulas, named figures, every Lösung and every nojs text without JavaScript.",
13
+ severity: "error",
14
+ run: ({ app }) => {
15
+ const marked = app?.pages.filter((page) => page.withoutJavaScript !== void 0) ?? [];
16
+ if (marked.length === 0) return void 0;
17
+ return marked.flatMap(({ page, withoutJavaScript }) => {
18
+ if (withoutJavaScript === void 0) return [];
19
+ const { h1, parts, exercises, components } = withoutJavaScript;
20
+ const problems = [];
21
+ if (h1 === "") problems.push({
22
+ where: page,
23
+ found: "without JavaScript the page shows no h1 heading: its content is not in the HTML, or hidden",
24
+ fix: RUNTIME_BUG
25
+ });
26
+ for (const part of parts) {
27
+ const what = WHAT[part.kind];
28
+ if (!part.visible) problems.push({
29
+ where: `${page} ${part.where}`,
30
+ found: `${what} is hidden without JavaScript`,
31
+ fix: RUNTIME_BUG
32
+ });
33
+ else if (part.text === "") problems.push({
34
+ where: `${page} ${part.where}`,
35
+ found: part.kind === "figure" ? `${what} has no accessible name` : `${what} is empty`,
36
+ fix: part.kind === "figure" ? "Give the figure its alt text and caption in every language (the block's alt and caption)" : RUNTIME_BUG
37
+ });
38
+ }
39
+ for (const { where, solutions } of exercises) {
40
+ if (solutions.length === 0) problems.push({
41
+ where: `${page} ${where}`,
42
+ found: "the exercise has no Lösung",
43
+ fix: RUNTIME_BUG
44
+ });
45
+ solutions.forEach((solution, index) => {
46
+ if (solution !== "") return;
47
+ problems.push({
48
+ where: `${page} ${where}`,
49
+ found: `${solutions.length > 1 ? `Lösung ${index + 1}` : "the Lösung"} is empty without JavaScript`,
50
+ fix: `Give the exercise its right answer in the content. ${RUNTIME_BUG}`
51
+ });
52
+ });
53
+ }
54
+ for (const { where, nojs } of components) {
55
+ if (nojs !== void 0 && nojs !== "") continue;
56
+ problems.push({
57
+ where: `${page} ${where}`,
58
+ found: nojs === void 0 ? "the component has no nojs text" : "the component's nojs text is empty, or hidden, without JavaScript",
59
+ fix: "Give the vue-component block a nojs text in every language: what the component shows and does, with its content (vue-component-nojs)"
60
+ });
61
+ }
62
+ return problems;
63
+ });
64
+ }
65
+ };
66
+ export { explainer_readable_without_js_default as default };
@@ -0,0 +1,10 @@
1
+ import { t as linkProblem } from "../../status-CXoQBgfU.mjs";
2
+ // check/rules/links-resolve.ts
3
+ var links_resolve_default = {
4
+ id: "links-resolve",
5
+ url: "https://lernapps.net/tooling/rules/#links-resolve",
6
+ description: "Every link and resource of the built app below its address resolves.",
7
+ severity: "error",
8
+ run: ({ app }) => app?.pages.flatMap(({ page, links }) => links.flatMap(({ url, status }) => linkProblem(page, url.slice(app.base.length) || "./", status, "Point the link to a page or file the build writes, or remove it") ?? []))
9
+ };
10
+ export { links_resolve_default as default };
@@ -0,0 +1,57 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ // check/lockfile.ts
4
+ /** The dependency fields npm copies from package.json into the lock file's root entry. */
5
+ const FIELDS = [
6
+ "dependencies",
7
+ "devDependencies",
8
+ "optionalDependencies",
9
+ "peerDependencies"
10
+ ];
11
+ /** The commit a git spec pins (github:owner/repo#<sha>, git+https://…#<sha>), if it pins a full one. */
12
+ const pinnedCommit = (spec) => spec.startsWith("file:") ? void 0 : /#([0-9a-f]{40})$/.exec(spec)?.[1];
13
+ const FIX = "Run npm install, then commit package-lock.json together with package.json";
14
+ /** The mismatches between package.json and package-lock.json in dir; undefined when dir has no lock file. */
15
+ function lockfileProblems(dir) {
16
+ const lockFile = join(dir, "package-lock.json");
17
+ if (!existsSync(lockFile) || !existsSync(join(dir, "package.json"))) return void 0;
18
+ const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
19
+ const lock = JSON.parse(readFileSync(lockFile, "utf8"));
20
+ const root = lock.packages?.[""] ?? {};
21
+ const problems = [];
22
+ const reported = /* @__PURE__ */ new Set();
23
+ for (const field of FIELDS) {
24
+ const wanted = manifest[field] ?? {};
25
+ const locked = root[field] ?? {};
26
+ for (const name of [.../* @__PURE__ */ new Set([...Object.keys(wanted), ...Object.keys(locked)])].sort()) {
27
+ if (wanted[name] === locked[name]) continue;
28
+ reported.add(name);
29
+ problems.push({
30
+ where: "package-lock.json",
31
+ found: `${field} ${name}: package.json has ${wanted[name] ?? "nothing"}, the lock file ${locked[name] ?? "nothing"}`,
32
+ fix: FIX
33
+ });
34
+ }
35
+ }
36
+ for (const field of FIELDS.filter((field) => field !== "peerDependencies")) for (const [name, spec] of Object.entries(manifest[field] ?? {})) {
37
+ const commit = pinnedCommit(spec);
38
+ if (commit === void 0 || reported.has(name)) continue;
39
+ const resolved = lock.packages?.[`node_modules/${name}`]?.resolved;
40
+ if (resolved?.endsWith(`#${commit}`)) continue;
41
+ problems.push({
42
+ where: "package-lock.json",
43
+ found: `${name}: package.json pins the commit ${commit}, the lock file installs ${resolved ?? "nothing"}`,
44
+ fix: FIX
45
+ });
46
+ }
47
+ return problems;
48
+ }
49
+ // check/rules/lockfile-matches.ts
50
+ var lockfile_matches_default = {
51
+ id: "lockfile-matches",
52
+ url: "https://lernapps.net/tooling/rules/#lockfile-matches",
53
+ description: "package-lock.json installs what package.json asks for, the commit of every git pin included.",
54
+ severity: "error",
55
+ run: ({ project }) => project === void 0 ? void 0 : lockfileProblems(project)
56
+ };
57
+ export { lockfile_matches_default as default };
@@ -0,0 +1,17 @@
1
+ // check/rules/no-request-before-click.ts
2
+ var no_request_before_click_default = {
3
+ id: "no-request-before-click",
4
+ url: "https://lernapps.net/tooling/rules/#no-request-before-click",
5
+ description: "No page of the built app requests anything from another host before a click.",
6
+ severity: "error",
7
+ run: ({ app }) => {
8
+ if (!app) return void 0;
9
+ const origin = new URL(app.base).origin;
10
+ return app.pages.flatMap(({ page, requests }) => [...new Set(requests.map((request) => request.url).filter((url) => new URL(url).origin !== origin))].map((url) => ({
11
+ where: page,
12
+ found: `requests ${url} before a click`,
13
+ fix: "Bundle it with the app at build time; load content of another server only after the learner clicks for it"
14
+ })));
15
+ }
16
+ };
17
+ export { no_request_before_click_default as default };
@@ -0,0 +1,17 @@
1
+ // check/rules/no-tracking.ts
2
+ var no_tracking_default = {
3
+ id: "no-tracking",
4
+ url: "https://lernapps.net/tooling/rules/#no-tracking",
5
+ description: "No page of the built app sets a cookie or sends data while it loads.",
6
+ severity: "error",
7
+ run: ({ app }) => app?.pages.flatMap(({ page, cookies, requests }) => [...cookies.map((cookie) => ({
8
+ where: page,
9
+ found: `sets the cookie "${cookie}"`,
10
+ fix: "Set no cookies; keep what the app must remember in localStorage, on the device"
11
+ })), ...requests.filter((request) => request.method !== "GET" && request.method !== "HEAD").map((request) => ({
12
+ where: page,
13
+ found: `sends data: ${request.method} ${request.url}`,
14
+ fix: "Send nothing to a server; keep what the app must remember in localStorage, on the device"
15
+ }))])
16
+ };
17
+ export { no_tracking_default as default };
@@ -0,0 +1,66 @@
1
+ import { t as FOREIGN_KEY } from "../../check-cv4I6CCH.mjs";
2
+ import { basename } from "node:path";
3
+ // check/rules/own-data-only.ts
4
+ const PREFIX = /^lernapps:([a-z0-9]+(?:-[a-z0-9]+)*):/;
5
+ /** The prefix of the learner's settings for more than one app (@lernapps/tooling/storage, createSettings). */
6
+ const SETTINGS = "lernapps:settings:";
7
+ /** Whether `name` is a setting for every app or for every app of `archetype`. */
8
+ function isSetting(name, archetype) {
9
+ if (!name.startsWith(SETTINGS)) return false;
10
+ const rest = name.slice(18);
11
+ const colon = rest.indexOf(":");
12
+ return colon === -1 ? rest !== "" : archetype !== void 0 && rest.slice(0, colon) === archetype;
13
+ }
14
+ const DOING = {
15
+ read: "reads",
16
+ write: "writes",
17
+ remove: "removes",
18
+ clear: "clears",
19
+ open: "opens",
20
+ delete: "deletes",
21
+ match: "looks up a request in"
22
+ };
23
+ var own_data_only_default = {
24
+ id: "own-data-only",
25
+ url: "https://lernapps.net/tooling/rules/#own-data-only",
26
+ description: "The app reads and writes only its own data in the browser, under the prefix lernapps:<id>:.",
27
+ severity: "error",
28
+ run: ({ app, entry }) => {
29
+ if (!app) return void 0;
30
+ const id = entry ? basename(entry.file).replace(/\.ya?ml$/, "") : void 0;
31
+ const named = app.pages.flatMap((page) => [...page.storage.map((stored) => stored.slice(stored.indexOf(" ") + 1)), ...page.access.filter((access) => access.action === "write" || access.action === "open").map((access) => access.name)]);
32
+ const read = app.pages.flatMap((page) => page.access.map((access) => access.name));
33
+ const foreign = PREFIX.exec(FOREIGN_KEY)?.[1];
34
+ const own = id ?? [...named, ...read].map((name) => PREFIX.exec(name)?.[1]).find((name) => name !== void 0 && name !== foreign && name !== "settings");
35
+ const prefix = own === void 0 ? void 0 : `lernapps:${own}:`;
36
+ const fix = prefix === void 0 ? "Name every key, database and cache with the prefix lernapps:<id>:, <id> the app's id in the catalog; with @lernapps/tooling/storage: createStorage(\"<id>\")" : `Name every key, database and cache with the prefix ${prefix} and touch nothing else; with @lernapps/tooling/storage: createStorage("${own}")`;
37
+ return (id === "settings" && app.pages[0] ? [{
38
+ where: app.pages[0].page,
39
+ found: "has the id settings, the prefix of the learner's settings for every app",
40
+ fix: "Give the entry another name"
41
+ }] : []).concat(app.pages.flatMap(({ page, access, storage, archetype }) => {
42
+ const isOwn = (name) => prefix !== void 0 && prefix !== SETTINGS && name.startsWith(prefix) || isSetting(name, archetype);
43
+ const problems = [];
44
+ const seen = /* @__PURE__ */ new Set();
45
+ const add = (found) => {
46
+ if (seen.has(found)) return;
47
+ seen.add(found);
48
+ problems.push({
49
+ where: page,
50
+ found,
51
+ fix
52
+ });
53
+ };
54
+ for (const { area, action, name } of access) if (action === "clear") add(`clears all of ${area}, the data of every app on lernapps.net`);
55
+ else if (action === "match") add("looks up a request in every cache, also in those of other apps");
56
+ else if (!isOwn(name)) add(`${DOING[action]} ${area} "${name}", which is not its own`);
57
+ for (const stored of storage) {
58
+ const area = stored.slice(0, stored.indexOf(" "));
59
+ const name = stored.slice(stored.indexOf(" ") + 1);
60
+ if (!isOwn(name)) add(`writes ${area} "${name}", which is not its own`);
61
+ }
62
+ return problems;
63
+ }));
64
+ }
65
+ };
66
+ export { own_data_only_default as default };
@@ -0,0 +1,25 @@
1
+ // check/rules/pages-reachable.ts
2
+ var pages_reachable_default = {
3
+ id: "pages-reachable",
4
+ url: "https://lernapps.net/tooling/rules/#pages-reachable",
5
+ description: "Every page of the built app is reachable from the start page by links; on a URL, at most 100 pages are checked.",
6
+ severity: "error",
7
+ run: ({ app }) => {
8
+ if (app === void 0) return void 0;
9
+ if (app.stopped !== void 0) {
10
+ const { limit, more } = app.stopped;
11
+ return [{
12
+ where: app.base,
13
+ found: `checked ${limit} pages, the most a check of an app on a URL visits; at least ${more} further pages linked from them were not visited, and no check ran on them`,
14
+ fix: "Nothing to fix for this: to check every page, check the built app itself (lernapps check in its repo, or lernapps check <dir> on its bundle)",
15
+ severity: "hint"
16
+ }];
17
+ }
18
+ return (app.unreachable ?? []).map((page) => ({
19
+ where: page,
20
+ found: "no link leads to this page from the start page: it is unreachable from the start page",
21
+ fix: "Link the page from a page learners reach, or leave it out of the build. A page reached another way on purpose gets <meta name=\"robots\" content=\"noindex\">"
22
+ }));
23
+ }
24
+ };
25
+ export { pages_reachable_default as default };