@lernapps/tooling 0.0.0-stage → 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.
- package/LICENSE +22 -0
- package/README.md +67 -2
- package/archetypes/shared/a11y.ts +38 -0
- package/archetypes/shared/hooks/pre-commit +22 -0
- package/archetypes/shared/hooks/pre-push +4 -0
- package/archetypes/shared/i18n.ts +25 -0
- package/archetypes/shared/install-hooks.ts +63 -0
- package/archetypes/shared/playwright.ts +54 -0
- package/archetypes/shared/preset.ts +81 -0
- package/archetypes/shared/storage.ts +167 -0
- package/archetypes/shared/tsconfig.json +27 -0
- package/check/validation-report.v1.schema.json +116 -0
- package/dist/a11y.mjs +30 -0
- package/dist/check/rules/accessible.mjs +18 -0
- package/dist/check/rules/app-content-guide.mjs +69 -0
- package/dist/check/rules/dependency-licence.mjs +82 -0
- package/dist/check/rules/entry-fitness-matches.mjs +22 -0
- package/dist/check/rules/entry-links-resolve.mjs +44 -0
- package/dist/check/rules/explainer-readable-without-js.mjs +66 -0
- package/dist/check/rules/links-resolve.mjs +10 -0
- package/dist/check/rules/lockfile-matches.mjs +57 -0
- package/dist/check/rules/no-request-before-click.mjs +17 -0
- package/dist/check/rules/no-tracking.mjs +17 -0
- package/dist/check/rules/own-data-only.mjs +66 -0
- package/dist/check/rules/pages-reachable.mjs +25 -0
- package/dist/check/rules/usable-at-360px.mjs +19 -0
- package/dist/check-cv4I6CCH.mjs +28 -0
- package/dist/cli.mjs +3118 -0
- package/dist/i18n.mjs +11 -0
- package/dist/lint.mjs +528 -0
- package/dist/plan-DWKayEnY.mjs +75 -0
- package/dist/playwright.mjs +47 -0
- package/dist/preset.mjs +60 -0
- package/dist/skills/lernapps-app/SKILL.md +49 -0
- package/dist/skills/lernapps-cli/SKILL.md +156 -0
- package/dist/skills/lernapps-cli/references/platform-rules.md +258 -0
- package/dist/skills/lernapps-commit/SKILL.md +39 -0
- package/dist/skills/lernapps-content-growth/SKILL.md +33 -0
- package/dist/skills/lernapps-content-questions/SKILL.md +133 -0
- package/dist/skills/lernapps-content-questions/references/question-types.md +69 -0
- package/dist/skills/lernapps-content-research/SKILL.md +50 -0
- package/dist/skills/lernapps-content-research/references/sources.md +16 -0
- package/dist/skills/lernapps-exercise-patterns/SKILL.md +74 -0
- package/dist/skills/lernapps-exercise-patterns/references/biology-geography.md +13 -0
- package/dist/skills/lernapps-exercise-patterns/references/chemistry.md +16 -0
- package/dist/skills/lernapps-exercise-patterns/references/computer-science.md +13 -0
- package/dist/skills/lernapps-exercise-patterns/references/figures.md +10 -0
- package/dist/skills/lernapps-exercise-patterns/references/history.md +13 -0
- package/dist/skills/lernapps-exercise-patterns/references/mathematics.md +19 -0
- package/dist/skills/lernapps-exercise-patterns/references/page-structures.md +8 -0
- package/dist/skills/lernapps-exercise-patterns/references/physics.md +18 -0
- package/dist/skills/lernapps-exercise-patterns/references/success.md +28 -0
- package/dist/skills/lernapps-explainer/SKILL.md +33 -0
- package/dist/skills/lernapps-explainer/references/blocks.md +83 -0
- package/dist/skills/lernapps-explainer/references/components.md +66 -0
- package/dist/skills/lernapps-explainer/references/exercises.md +115 -0
- package/dist/skills/lernapps-explainer/references/rules.md +285 -0
- package/dist/skills/lernapps-explainer/references/runtime.md +79 -0
- package/dist/skills/lernapps-explainer/references/structure.md +24 -0
- package/dist/skills/lernapps-explainer-design/SKILL.md +57 -0
- package/dist/skills/lernapps-explainer-design/references/rules.md +75 -0
- package/dist/skills/lernapps-explainer-self-assessment/SKILL.md +93 -0
- package/dist/skills/lernapps-github/SKILL.md +102 -0
- package/dist/skills/lernapps-interactive/SKILL.md +141 -0
- package/dist/skills/lernapps-interactive/references/frame.md +30 -0
- package/dist/skills/lernapps-interactive-design/SKILL.md +58 -0
- package/dist/skills/lernapps-listing/SKILL.md +44 -0
- package/dist/skills/lernapps-listing/references/keeping-updated.md +33 -0
- package/dist/skills/lernapps-quiz/SKILL.md +100 -0
- package/dist/skills/lernapps-quiz-design/SKILL.md +48 -0
- package/dist/status-CXoQBgfU.mjs +76 -0
- package/dist/storage.mjs +113 -0
- package/generator/app/.agents/skills/app-content/SKILL.md +63 -0
- package/generator/app/.github/ISSUE_TEMPLATE/inhaltsfehler.yml +25 -0
- package/generator/app/.github/workflows/pages.yml +61 -0
- package/generator/app/LICENSE +21 -0
- package/generator/app/renovate.json +4 -0
- package/generator/architecture.ts +212 -0
- package/generator/create.ts +462 -0
- package/generator/init.ts +186 -0
- package/generator/update.ts +429 -0
- package/guidance/AGENTS.md +26 -0
- package/guidance/increment-template.md +194 -0
- package/guidance/plan-front-matter.v1.schema.json +36 -0
- package/guidance/plan-template.md +233 -0
- package/guidance/subagent-brief.md +25 -0
- package/guidance/workflow-rules.md +109 -0
- package/package.json +135 -4
- package/review/verdict.v1.schema.json +134 -0
- package/skills/archetypes/lernapps-explainer/SKILL.md +33 -0
- package/skills/archetypes/lernapps-explainer/references/blocks.md +83 -0
- package/skills/archetypes/lernapps-explainer/references/components.md +66 -0
- package/skills/archetypes/lernapps-explainer/references/exercises.md +115 -0
- package/skills/archetypes/lernapps-explainer/references/rules.md +285 -0
- package/skills/archetypes/lernapps-explainer/references/runtime.md +79 -0
- package/skills/archetypes/lernapps-explainer/references/structure.md +24 -0
- package/skills/archetypes/lernapps-explainer-design/SKILL.md +57 -0
- package/skills/archetypes/lernapps-explainer-design/references/rules.md +75 -0
- package/skills/archetypes/lernapps-explainer-self-assessment/SKILL.md +93 -0
- package/skills/archetypes/lernapps-interactive/SKILL.md +141 -0
- package/skills/archetypes/lernapps-interactive/references/frame.md +30 -0
- package/skills/archetypes/lernapps-interactive-design/SKILL.md +58 -0
- package/skills/archetypes/lernapps-quiz/SKILL.md +100 -0
- package/skills/archetypes/lernapps-quiz-design/SKILL.md +48 -0
- package/skills/content/lernapps-content-questions/SKILL.md +133 -0
- package/skills/content/lernapps-content-questions/references/question-types.md +69 -0
- package/skills/content/lernapps-content-research/SKILL.md +50 -0
- package/skills/content/lernapps-content-research/references/sources.md +16 -0
- package/skills/content/lernapps-exercise-patterns/SKILL.md +74 -0
- package/skills/content/lernapps-exercise-patterns/references/biology-geography.md +13 -0
- package/skills/content/lernapps-exercise-patterns/references/chemistry.md +16 -0
- package/skills/content/lernapps-exercise-patterns/references/computer-science.md +13 -0
- package/skills/content/lernapps-exercise-patterns/references/figures.md +10 -0
- package/skills/content/lernapps-exercise-patterns/references/history.md +13 -0
- package/skills/content/lernapps-exercise-patterns/references/mathematics.md +19 -0
- package/skills/content/lernapps-exercise-patterns/references/page-structures.md +8 -0
- package/skills/content/lernapps-exercise-patterns/references/physics.md +18 -0
- package/skills/content/lernapps-exercise-patterns/references/success.md +28 -0
- package/skills/process/lernapps-app/SKILL.md +49 -0
- package/skills/process/lernapps-commit/SKILL.md +39 -0
- package/skills/process/lernapps-content-growth/SKILL.md +33 -0
- package/skills/process/lernapps-github/SKILL.md +102 -0
- package/skills/process/lernapps-listing/SKILL.md +44 -0
- package/skills/process/lernapps-listing/references/keeping-updated.md +33 -0
- package/skills/tooling/lernapps-cli/SKILL.md +156 -0
- package/skills/tooling/lernapps-cli/references/platform-rules.md +258 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 the lernapps.net contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/README.md
CHANGED
|
@@ -1,3 +1,68 @@
|
|
|
1
|
-
#
|
|
1
|
+
# tooling
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The tooling with which a creator and their AI assistant build a learning app for
|
|
4
|
+
[lernapps.net](https://lernapps.net/): an app that runs in the browser, collects nothing, is accessible, and is
|
|
5
|
+
listed in the app overview within minutes. The creator publishes the app at their own address, by default their own
|
|
6
|
+
GitHub Pages; once listed, lernapps.net serves a copy of it at `lernapps.net/apps/start/<id>/`.
|
|
7
|
+
|
|
8
|
+
The tooling is responsible for helping builders get their apps out. The [app overview](https://github.com/lernapps/apps)
|
|
9
|
+
explains building and listing, and provides the apps themselves: the catalog.
|
|
10
|
+
|
|
11
|
+
The repo also holds the platform's upkeep, which creators don't use: the actions that check, publish and preview
|
|
12
|
+
lernapps.net's own websites, and the update policy for dependencies.
|
|
13
|
+
|
|
14
|
+
## Who it is for
|
|
15
|
+
|
|
16
|
+
- **Creators and their assistants** who build an app. The creator needs no development skills; the assistant
|
|
17
|
+
follows the guidance and the checks.
|
|
18
|
+
- **The platform owner and the review agent**, who decide whether an app is listed.
|
|
19
|
+
- **Maintainers of the lernapps repos**, who change the rules, the checks and the guidance.
|
|
20
|
+
|
|
21
|
+
## Where to start
|
|
22
|
+
|
|
23
|
+
- **Creators:** [lernapps.net/apps/build/](https://lernapps.net/apps/build/) says what to tell your assistant.
|
|
24
|
+
- **Assistants:** read [lernapps.net/tooling/llms.txt](https://lernapps.net/tooling/llms.txt). It starts the build
|
|
25
|
+
with the plan template.
|
|
26
|
+
- **The [skills](skills/):** one per phase, grouped by what they hold: `process/`, `tooling/`, `content/`,
|
|
27
|
+
`archetypes/`. The [platform rules](skills/tooling/lernapps-cli/references/platform-rules.md) hold for every app,
|
|
28
|
+
however it is built.
|
|
29
|
+
- **The [plan template](guidance/plan-template.md):** the whole workflow of an app's first version, from the first
|
|
30
|
+
question to the listing. It names each skill at the step where it is needed. Every later change follows the
|
|
31
|
+
[increment template](guidance/increment-template.md), in a plan of its own.
|
|
32
|
+
- **The [architecture](https://lernapps.net/tooling/architecture/):** what each part is responsible for and why.
|
|
33
|
+
- **The [rule catalog](https://lernapps.net/tooling/rules/):** every rule by id, with where it is told and checked.
|
|
34
|
+
|
|
35
|
+
## What is in it
|
|
36
|
+
|
|
37
|
+
- **The package `@lernapps/tooling`**, on [npm](https://www.npmjs.com/package/@lernapps/tooling) (released from CI with provenance, `.github/workflows/release.yml`): the guidance, the skills, the shared preset
|
|
38
|
+
and lint rules of every app, and the CLI.
|
|
39
|
+
- **The CLI `lernapps`:** `init` starts an app's plan in an empty folder and syncs the skills of the packages into the agent directories, `skills --archetype` adds those of the chosen archetype, `create` writes a new app for an archetype once the creator confirmed the plan, `check`
|
|
40
|
+
checks an app the same way in its git hooks and in CI, `update` moves an app to the runtime and tooling versions of the latest
|
|
41
|
+
tooling and replaces the app's `lernapps-*` skills with that tooling's (and an older app's single plan into
|
|
42
|
+
`.vibe/plans/`), `changes` tells what a change can affect, so that hooks and CI run only that, `screenshots` writes an image of every page and every figure of a built explainer or quiz, to show a creator what a learner sees (`npx lernapps screenshots --help`). `npx --yes @lernapps/tooling --help` before an app exists, `npx lernapps --help` in one.
|
|
43
|
+
- **The actions in [`actions/`](actions/):** the app check, app publish and app preview actions of every app, and the site
|
|
44
|
+
actions with which lernapps.net's own websites are checked, published and previewed. Each says what it does in its
|
|
45
|
+
`action.yml`. The reusable workflow [`changes.yml`](.github/workflows/changes.yml) gives other workflows the
|
|
46
|
+
change scope, and its comments show how a required check never stays "Pending".
|
|
47
|
+
- **The [documentation site](https://lernapps.net/tooling/):** the architecture, the rule catalog, the published
|
|
48
|
+
schemas and the `llms.txt`.
|
|
49
|
+
- **The review** in [`review/`](review/): the procedure and rubric of the agent that reviews an app before listing.
|
|
50
|
+
- **The Renovate preset** [`default.json`](default.json), which every lernapps repo extends.
|
|
51
|
+
|
|
52
|
+
The details live where you use them: the help of each command, the description of each action, the error messages,
|
|
53
|
+
and the comments in the code.
|
|
54
|
+
|
|
55
|
+
## Contributing
|
|
56
|
+
|
|
57
|
+
`npm ci`, then `npm run check` runs exactly what CI runs. The pre-push hook runs a fast subset when a push changes no code.
|
|
58
|
+
|
|
59
|
+
## Links
|
|
60
|
+
|
|
61
|
+
- [Documentation site](https://lernapps.net/tooling/), with the published schemas
|
|
62
|
+
- [Platform design](https://lernapps.net/docs/platform-design/)
|
|
63
|
+
- [App templates](https://github.com/lernapps/app-templates)
|
|
64
|
+
- [App overview](https://github.com/lernapps/apps)
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Accessibility helpers of a lernapps app (@lernapps/tooling/a11y): what interactive apps need beyond semantic HTML,
|
|
2
|
+
// so that feedback reaches screen readers and keyboard users as it reaches everyone else.
|
|
3
|
+
|
|
4
|
+
const regions = new Map<string, HTMLElement>();
|
|
5
|
+
|
|
6
|
+
/** A visually hidden live region, created once per politeness. */
|
|
7
|
+
function region(politeness: "polite" | "assertive"): HTMLElement {
|
|
8
|
+
const existing = regions.get(politeness);
|
|
9
|
+
if (existing?.isConnected) return existing;
|
|
10
|
+
const element = document.createElement("div");
|
|
11
|
+
element.setAttribute("aria-live", politeness);
|
|
12
|
+
element.setAttribute("aria-atomic", "true");
|
|
13
|
+
element.style.cssText =
|
|
14
|
+
"position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0";
|
|
15
|
+
document.body.append(element);
|
|
16
|
+
regions.set(politeness, element);
|
|
17
|
+
return element;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Says `text` to screen readers, e.g. the feedback after an answer; "assertive" interrupts, for errors only. */
|
|
21
|
+
export function announce(text: string, politeness: "polite" | "assertive" = "polite"): void {
|
|
22
|
+
const live = region(politeness);
|
|
23
|
+
live.textContent = "";
|
|
24
|
+
// a new text node after the region is cleared, so that the same text is announced again
|
|
25
|
+
setTimeout(() => {
|
|
26
|
+
live.textContent = text;
|
|
27
|
+
}, 50);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Moves the keyboard focus to `element`, e.g. the heading of the next task, making it focusable if needed. */
|
|
31
|
+
export function moveFocus(element: HTMLElement): void {
|
|
32
|
+
if (!element.matches("a[href], button, input, select, textarea, [tabindex]")) element.tabIndex = -1;
|
|
33
|
+
element.focus();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Whether the learner asked for less motion: animate only when this is false. */
|
|
37
|
+
export const prefersReducedMotion = (): boolean =>
|
|
38
|
+
globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# The pre-commit hook of every lernapps app (installed by the preset): formats the staged files, then runs
|
|
2
|
+
# the fast part of the checks (format, lint, type check; an interactive app's architecture with arc42 diff), the
|
|
3
|
+
# first half of what CI runs. npm install installs both hooks through the app's prepare script, as the generator
|
|
4
|
+
# writes it: lernapps hooks (archetypes/shared/install-hooks.ts)
|
|
5
|
+
# Quiet: everything the steps print goes to the log below. On success the hook prints nothing; on a failure it prints
|
|
6
|
+
# one line: the step, its exit code and the log, which holds the full output. CI runs the same command and prints it.
|
|
7
|
+
# A commit with nothing staged (the empty start commits of main and gh-pages) has nothing to format or check.
|
|
8
|
+
git diff --cached --quiet && exit 0
|
|
9
|
+
log=node_modules/.cache/lernapps/pre-commit.log
|
|
10
|
+
mkdir -p "$(dirname "$log")"
|
|
11
|
+
: >"$log"
|
|
12
|
+
step() {
|
|
13
|
+
name=$1
|
|
14
|
+
shift
|
|
15
|
+
echo "\$ $*" >>"$log"
|
|
16
|
+
"$@" >>"$log" 2>&1 && return 0
|
|
17
|
+
code=$?
|
|
18
|
+
echo "lernapps pre-commit: $name failed (exit $code); the full output is in $log" >&2
|
|
19
|
+
exit "$code"
|
|
20
|
+
}
|
|
21
|
+
step "format the staged files (vp staged)" vp staged
|
|
22
|
+
step "the fast checks (lernapps check --pre-commit)" lernapps check --pre-commit
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
# The pre-push hook of every lernapps app (installed by the preset): the heavy part of the checks (unit tests, build,
|
|
2
|
+
# checks of the built app, end-to-end tests), the second half of what CI runs. A failure increments prePushFailures
|
|
3
|
+
# in the plan file.
|
|
4
|
+
lernapps check --pre-push
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Learner texts from the app's message files (@lernapps/tooling/i18n; lint rule learner-text-german): the app keeps
|
|
2
|
+
// its texts in src/messages/de.json and shows them through a translator, never as strings in code.
|
|
3
|
+
//
|
|
4
|
+
// import de from "./messages/de.json";
|
|
5
|
+
// const t = translator(de);
|
|
6
|
+
// t("solved", { count: 3 }); // "Du hast {count} Aufgaben gelöst." → "Du hast 3 Aufgaben gelöst."
|
|
7
|
+
|
|
8
|
+
/** The texts of one language, by key. */
|
|
9
|
+
export type Messages = Readonly<Record<string, string>>;
|
|
10
|
+
/** Values for the placeholders of a text, `{name}`; numbers are formatted for the language. */
|
|
11
|
+
export type Params = Readonly<Record<string, string | number>>;
|
|
12
|
+
|
|
13
|
+
/** The text of `key`, with its placeholders filled; a placeholder without a value stays as it is. */
|
|
14
|
+
export function translator<M extends Messages>(
|
|
15
|
+
messages: M,
|
|
16
|
+
locale = "de-DE",
|
|
17
|
+
): (key: keyof M & string, params?: Params) => string {
|
|
18
|
+
const numbers = new Intl.NumberFormat(locale);
|
|
19
|
+
return (key, params = {}) =>
|
|
20
|
+
(messages[key] ?? key).replace(/\{(\w+)\}/g, (placeholder, name: string) => {
|
|
21
|
+
const value = params[name];
|
|
22
|
+
if (value === undefined) return placeholder;
|
|
23
|
+
return typeof value === "number" ? numbers.format(value) : value;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// `lernapps hooks`: installs the git hooks of the preset (archetypes/shared/hooks/) into the app's repository, as
|
|
2
|
+
// the app's prepare script runs it on every npm install. Only where the app's folder is the root of its git work
|
|
3
|
+
// tree: a folder inside another repository (a template in lernapps/app-templates, an app in a monorepo) leaves that
|
|
4
|
+
// repository's hooks alone, since vp config would point the whole repository at the app's hooks.
|
|
5
|
+
//
|
|
6
|
+
// git runs the hooks from a folder of their own below node_modules/.cache/, each a line that runs the preset's hook
|
|
7
|
+
// of the installed tooling. Not from the package's folder: npm replaces that whenever the tooling moves to another
|
|
8
|
+
// commit (lernapps update installs it by name, which runs no prepare script), and git skips a hooks folder that is gone
|
|
9
|
+
// without a word, so the commits after an update would pass unchecked.
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { delimiter, join } from "node:path";
|
|
13
|
+
import { parseArgs } from "node:util";
|
|
14
|
+
import { HOOKS_OPTIONS, HOOKS_USAGE } from "../../src/usage.ts";
|
|
15
|
+
|
|
16
|
+
/** The hooks, as the app's folder sees them once @lernapps/tooling is installed. */
|
|
17
|
+
export const HOOKS_DIR = "node_modules/@lernapps/tooling/archetypes/shared/hooks";
|
|
18
|
+
/** Where git runs them from: a folder npm leaves alone when it replaces the package. */
|
|
19
|
+
export const GIT_HOOKS_DIR = "node_modules/.cache/lernapps/hooks";
|
|
20
|
+
const HOOKS = ["pre-commit", "pre-push"] as const;
|
|
21
|
+
|
|
22
|
+
export function installHooks(args: string[]): number {
|
|
23
|
+
const { values } = parseArgs({ args, options: HOOKS_OPTIONS, strict: true, allowPositionals: false });
|
|
24
|
+
if (values.help) {
|
|
25
|
+
process.stdout.write(HOOKS_USAGE);
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
const say = (message: string) => process.stdout.write(`lernapps hooks: ${message}\n`);
|
|
29
|
+
const prefix = spawnSync("git", ["rev-parse", "--show-prefix"], { encoding: "utf8" });
|
|
30
|
+
if (prefix.error || prefix.status !== 0) {
|
|
31
|
+
say("not in a git repository, no hooks installed (git init, then npm install again)");
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
const inside = prefix.stdout.trim();
|
|
35
|
+
if (inside !== "") {
|
|
36
|
+
say(`this folder is ${inside} inside another git repository; its hooks stay as they are`);
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
// a missing hook of the package fails the commit or push: sh cannot read the file
|
|
40
|
+
mkdirSync(GIT_HOOKS_DIR, { recursive: true });
|
|
41
|
+
for (const hook of HOOKS) {
|
|
42
|
+
writeFileSync(
|
|
43
|
+
join(GIT_HOOKS_DIR, hook),
|
|
44
|
+
`# Written by lernapps hooks: runs the ${hook} hook of the installed @lernapps/tooling.\n. ./${HOOKS_DIR}/${hook}\n`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
// an app installed before the hooks had their own folder: vp config leaves any other core.hooksPath as it is
|
|
48
|
+
const current = spawnSync("git", ["config", "--local", "core.hooksPath"], { encoding: "utf8" });
|
|
49
|
+
if (current.stdout.trim() === `${HOOKS_DIR}/_`) {
|
|
50
|
+
spawnSync("git", ["config", "--local", "core.hooksPath", `${GIT_HOOKS_DIR}/_`]);
|
|
51
|
+
}
|
|
52
|
+
// the app's toolchain first, as npm puts it on the PATH of the prepare script
|
|
53
|
+
const env = {
|
|
54
|
+
...process.env,
|
|
55
|
+
PATH: [join(process.cwd(), "node_modules", ".bin"), process.env["PATH"] ?? ""].join(delimiter),
|
|
56
|
+
};
|
|
57
|
+
const result = spawnSync("vp", ["config", "--no-agent", "--hooks-dir", GIT_HOOKS_DIR], { stdio: "inherit", env });
|
|
58
|
+
if (result.error) {
|
|
59
|
+
process.stderr.write(`lernapps hooks: cannot run vp: ${result.error.message}. Install the dependencies (npm ci)\n`);
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
|
62
|
+
return result.status ?? 1;
|
|
63
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The Playwright configuration of a lernapps app (@lernapps/tooling/playwright): end-to-end tests in e2e/*.e2e.ts
|
|
2
|
+
// against the built app, served by `vp preview` on a free port, in Chromium at desktop width and at 360 px. An app's
|
|
3
|
+
// playwright.config.ts is
|
|
4
|
+
//
|
|
5
|
+
// export { default } from "@lernapps/tooling/playwright";
|
|
6
|
+
//
|
|
7
|
+
// or `export default playwright({ ... })` with its own settings over these. `lernapps check --pre-push` runs them.
|
|
8
|
+
import { createServer } from "node:net";
|
|
9
|
+
import { defineConfig, devices, type PlaywrightTestConfig } from "playwright/test";
|
|
10
|
+
|
|
11
|
+
/** A port nobody listens on now, so that several apps (or test runs) can check at the same time. */
|
|
12
|
+
const freePort = () =>
|
|
13
|
+
new Promise<number>((resolve, reject) => {
|
|
14
|
+
const server = createServer();
|
|
15
|
+
server.on("error", reject);
|
|
16
|
+
server.listen(0, "127.0.0.1", () => {
|
|
17
|
+
const address = server.address();
|
|
18
|
+
server.close(() => resolve(typeof address === "object" && address !== null ? address.port : 4317));
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Chosen once, when Playwright loads this configuration; its workers inherit the variable and load the same port.
|
|
23
|
+
const PORT = (process.env["LERNAPPS_PREVIEW_PORT"] ??= String(await freePort()));
|
|
24
|
+
const URL = `http://127.0.0.1:${PORT}/`;
|
|
25
|
+
|
|
26
|
+
const base: PlaywrightTestConfig = {
|
|
27
|
+
testDir: "e2e",
|
|
28
|
+
testMatch: "*.e2e.ts",
|
|
29
|
+
forbidOnly: true,
|
|
30
|
+
// Every test opens its own page and depends on no other: they run in parallel, not one file after another, on
|
|
31
|
+
// three quarters of the machine's cores; the rest serves the app (vp preview). LERNAPPS_PLAYWRIGHT_WORKERS sets
|
|
32
|
+
// another number of workers: the tooling's own tests set 1, because they run many apps' checks at the same time.
|
|
33
|
+
fullyParallel: true,
|
|
34
|
+
workers: Number(process.env["LERNAPPS_PLAYWRIGHT_WORKERS"]) || "75%",
|
|
35
|
+
reporter: [["line"]],
|
|
36
|
+
// LERNAPPS_CHROMIUM: an installed Chromium that lernapps check starts where Playwright's own is missing
|
|
37
|
+
// (check/visit.ts)
|
|
38
|
+
use: {
|
|
39
|
+
baseURL: URL,
|
|
40
|
+
...(process.env["LERNAPPS_CHROMIUM"]
|
|
41
|
+
? { launchOptions: { executablePath: process.env["LERNAPPS_CHROMIUM"] } }
|
|
42
|
+
: {}),
|
|
43
|
+
},
|
|
44
|
+
projects: [
|
|
45
|
+
{ name: "desktop", use: { ...devices["Desktop Chrome"] } },
|
|
46
|
+
{ name: "360px", use: { ...devices["Desktop Chrome"], viewport: { width: 360, height: 740 } } },
|
|
47
|
+
],
|
|
48
|
+
webServer: { command: `vp preview --host 127.0.0.1 --port ${PORT} --strictPort`, url: URL },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The configuration, with the app's own settings over it. */
|
|
52
|
+
export const playwright = (config: PlaywrightTestConfig = {}) => defineConfig(base, config);
|
|
53
|
+
|
|
54
|
+
export default playwright();
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The shared base of the archetype presets (docs/arc42 ch. 5, "Archetype presets"): the vite-plus configuration an
|
|
2
|
+
// app extends and never edits. Its vite.config.ts is
|
|
3
|
+
//
|
|
4
|
+
// import { lernapps } from "@lernapps/tooling/preset";
|
|
5
|
+
// export default lernapps();
|
|
6
|
+
//
|
|
7
|
+
// with the app's own settings, if any, as the argument (merged over the preset). It sets lint (the lernapps lint
|
|
8
|
+
// plugin and the severity of each of its rules, type-aware with type check, no `any`), format, unit tests, the
|
|
9
|
+
// staged files of the pre-commit hook, and a build with relative URLs, so the app works under any path.
|
|
10
|
+
import { defineConfig, mergeConfig, type UserConfig } from "vite-plus";
|
|
11
|
+
|
|
12
|
+
/** Not code: what tools write, and prose. */
|
|
13
|
+
const notCode = ["dist/**", "node_modules/**", "playwright-report/**", "test-results/**", "**/*.md"];
|
|
14
|
+
|
|
15
|
+
/** The severity of each rule of the lint plugin (lint/rules/<id>.ts), as `lernapps/<id>`. */
|
|
16
|
+
const lernappsRules = {
|
|
17
|
+
"lernapps/learner-text-german": "error",
|
|
18
|
+
"lernapps/no-request-before-click": "error",
|
|
19
|
+
"lernapps/storage-through-wrapper": "error",
|
|
20
|
+
"lernapps/suppression-reason": "error",
|
|
21
|
+
"lernapps/vue-component-browser-globals": "error",
|
|
22
|
+
"lernapps/vue-component-imports": "error",
|
|
23
|
+
"lernapps/vue-component-no-html-injection": "error",
|
|
24
|
+
"lernapps/vue-component-no-network": "error",
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
const preset: UserConfig = {
|
|
28
|
+
base: "./",
|
|
29
|
+
fmt: {
|
|
30
|
+
printWidth: 120,
|
|
31
|
+
ignorePatterns: notCode,
|
|
32
|
+
},
|
|
33
|
+
lint: {
|
|
34
|
+
ignorePatterns: notCode,
|
|
35
|
+
jsPlugins: ["@lernapps/tooling/lint"],
|
|
36
|
+
options: {
|
|
37
|
+
typeAware: true,
|
|
38
|
+
typeCheck: true,
|
|
39
|
+
},
|
|
40
|
+
rules: {
|
|
41
|
+
...lernappsRules,
|
|
42
|
+
"typescript/no-explicit-any": "error",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
test: {
|
|
46
|
+
include: ["src/**/*.test.ts", "test/**/*.test.ts"],
|
|
47
|
+
},
|
|
48
|
+
staged: {
|
|
49
|
+
// Formats the staged files before the hook runs the fast part of `lernapps check`, which lints and type-checks the
|
|
50
|
+
// whole app once: `vp check --fix` here would type-check the staged files a second time.
|
|
51
|
+
"*.{ts,tsx,json}": "vp fmt",
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Whether the plugins (nested lists, as Vite takes them) hold one named `name`. */
|
|
56
|
+
const hasPlugin = (plugins: unknown, name: string): boolean =>
|
|
57
|
+
Array.isArray(plugins)
|
|
58
|
+
? plugins.some((plugin) => hasPlugin(plugin, name))
|
|
59
|
+
: typeof plugins === "object" && plugins !== null && "name" in plugins && plugins.name === name;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The preset, with the app's own settings merged over it. With the interactive runtime's build step among the
|
|
63
|
+
* plugins, a component may also import the app's own files in src/ and the libraries in its dependencies, as that
|
|
64
|
+
* build allows (lint/rules/vue-component-imports.ts).
|
|
65
|
+
*/
|
|
66
|
+
export const lernapps = (config: UserConfig = {}): UserConfig => {
|
|
67
|
+
const interactive = hasPlugin(config.plugins, "lernapps-interactive");
|
|
68
|
+
// set in place, not merged: mergeConfig would concatenate the rule's severity with its options
|
|
69
|
+
const base: UserConfig = interactive
|
|
70
|
+
? {
|
|
71
|
+
...preset,
|
|
72
|
+
lint: {
|
|
73
|
+
...preset.lint,
|
|
74
|
+
rules: { ...preset.lint?.rules, "lernapps/vue-component-imports": ["error", { scope: "src" }] },
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
: preset;
|
|
78
|
+
return defineConfig(mergeConfig(base, config));
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export default lernapps();
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// The only storage of a lernapps app (@lernapps/tooling/storage; lint rule storage-through-wrapper): on the device,
|
|
2
|
+
// in localStorage, under the app's own prefix, because every app on lernapps.net shares one origin. That origin's
|
|
3
|
+
// localStorage is small (about 5 MB in most browsers) and shared, so an app keeps at most STORAGE_QUOTA under its
|
|
4
|
+
// prefix: a save beyond it is refused. The learner's settings for every app or every app of an archetype are kept
|
|
5
|
+
// under SETTINGS_PREFIX, which every app shares, within SETTINGS_QUOTA (createSettings). Every access is wrapped in try/catch: when the browser blocks storage, it is
|
|
6
|
+
// full or the app's quota is used up, the app keeps working and remembers nothing more. What should last only while the
|
|
7
|
+
// tab is open, such as an answer being written, which a reload must not lose but a new day need not keep, goes into
|
|
8
|
+
// sessionStorage instead ({ type: "sessionStorage" }), with the same prefix and quota.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What an app may keep under its prefix: 100 kB (100,000 bytes), measured as browsers count localStorage, 2 bytes per
|
|
12
|
+
* UTF-16 code unit of every key (with the prefix) and value.
|
|
13
|
+
*/
|
|
14
|
+
export const STORAGE_QUOTA = 100_000;
|
|
15
|
+
|
|
16
|
+
/** What an app keeps on the device: JSON values under its own keys. */
|
|
17
|
+
export interface DeviceStorage {
|
|
18
|
+
/** The value saved under `key`, if there is one and `guard` accepts it. */
|
|
19
|
+
load<T>(key: string, guard: (value: unknown) => value is T): T | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Saves a JSON value; false when the browser does not let the app store it, or when the app's values would then
|
|
22
|
+
* take more than STORAGE_QUOTA. A refused save keeps the value saved before.
|
|
23
|
+
*/
|
|
24
|
+
save(key: string, value: unknown): boolean;
|
|
25
|
+
/** Removes the value saved under `key`. */
|
|
26
|
+
remove(key: string): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Where the values are kept: until removed (localStorage, the default), or while the tab is open (sessionStorage). */
|
|
30
|
+
export type StorageType = "localStorage" | "sessionStorage";
|
|
31
|
+
|
|
32
|
+
export interface StorageOptions {
|
|
33
|
+
type?: StorageType;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The storage of the app named `app`: its id in the catalog (entries/<id>.yaml, rule own-data-only), e.g. "brueche";
|
|
38
|
+
* in localStorage, or with `type: "sessionStorage"` in sessionStorage, which the browser empties when the tab closes.
|
|
39
|
+
*/
|
|
40
|
+
export function createStorage(app: string, options: StorageOptions = {}): DeviceStorage {
|
|
41
|
+
return prefixed(`lernapps:${app}:`, `lernapps:${app}:`, STORAGE_QUOTA, options.type);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The values under `prefix` in the browser's storage `type`, which may take at most `quota` bytes together with every
|
|
46
|
+
* other value under `counted` there.
|
|
47
|
+
*/
|
|
48
|
+
function prefixed(prefix: string, counted: string, quota: number, type: StorageType = "localStorage"): DeviceStorage {
|
|
49
|
+
/** The bytes the values under `counted` take, without the one under `except`. */
|
|
50
|
+
const used = (target: Storage, except: string): number => {
|
|
51
|
+
let bytes = 0;
|
|
52
|
+
for (let i = 0; i < target.length; i++) {
|
|
53
|
+
const key = target.key(i);
|
|
54
|
+
if (key === null || key === except || !key.startsWith(counted)) continue;
|
|
55
|
+
bytes += (key.length + (target.getItem(key)?.length ?? 0)) * 2;
|
|
56
|
+
}
|
|
57
|
+
return bytes;
|
|
58
|
+
};
|
|
59
|
+
const store = (): Storage | undefined => {
|
|
60
|
+
try {
|
|
61
|
+
return globalThis[type];
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
load(key, guard) {
|
|
68
|
+
try {
|
|
69
|
+
const text = store()?.getItem(prefix + key);
|
|
70
|
+
if (text === null || text === undefined) return undefined;
|
|
71
|
+
const value: unknown = JSON.parse(text);
|
|
72
|
+
return guard(value) ? value : undefined;
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
save(key, value) {
|
|
78
|
+
try {
|
|
79
|
+
const target = store();
|
|
80
|
+
if (!target) return false;
|
|
81
|
+
const text = String(JSON.stringify(value));
|
|
82
|
+
if (used(target, prefix + key) + (prefix.length + key.length + text.length) * 2 > quota) return false;
|
|
83
|
+
target.setItem(prefix + key, text);
|
|
84
|
+
return true;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
remove(key) {
|
|
90
|
+
try {
|
|
91
|
+
store()?.removeItem(prefix + key);
|
|
92
|
+
} catch {
|
|
93
|
+
// nothing stored, nothing to remove
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* What the learner's settings for every app and for every app of an archetype take together under SETTINGS_PREFIX:
|
|
101
|
+
* 10 kB (10,000 bytes), counted as STORAGE_QUOTA is. A setting for one app only counts towards that app's quota.
|
|
102
|
+
*/
|
|
103
|
+
export const SETTINGS_QUOTA = 10_000;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Where the learner's settings for more than one app are kept: `lernapps:settings:<key>` for every app,
|
|
107
|
+
* `lernapps:settings:<archetype>:<key>` for every app of an archetype. `settings` is never the id of an app.
|
|
108
|
+
*/
|
|
109
|
+
export const SETTINGS_PREFIX = "lernapps:settings:";
|
|
110
|
+
|
|
111
|
+
/** Where a setting holds: for this app only, for every app of its archetype, or for every app. */
|
|
112
|
+
export type SettingScope = "app" | "archetype" | "all";
|
|
113
|
+
|
|
114
|
+
/** The learner's settings as one app sees them (`createSettings`). */
|
|
115
|
+
export interface Settings {
|
|
116
|
+
/** The setting under `key` that holds for this app, the most specific one, with where it holds. */
|
|
117
|
+
load<T>(key: string, guard: (value: unknown) => value is T): { value: T; scope: SettingScope } | undefined;
|
|
118
|
+
/**
|
|
119
|
+
* Saves a setting where it holds and removes the more specific ones this app would read first, so that it takes
|
|
120
|
+
* effect here too (for every app also the archetype's, which other apps of the archetype then lose); false when the browser does not let the app store it, the quota is used up, the app has no archetype
|
|
121
|
+
* for `archetype`, or `key` has a colon. A refused save changes nothing.
|
|
122
|
+
*/
|
|
123
|
+
save(key: string, value: unknown, scope: SettingScope): boolean;
|
|
124
|
+
/** Removes the setting under `key` where it holds; one that is more general may then hold. */
|
|
125
|
+
remove(key: string, scope: SettingScope): void;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The learner's settings for the app named `app` (its id, as for `createStorage`) of the archetype `archetype`
|
|
130
|
+
* (`explainer`, as its pages mark it with `<meta name="lernapps-archetype">`): the app's own one under
|
|
131
|
+
* `lernapps:<app>:settings:<key>` goes before the archetype's, which goes before the one for every app
|
|
132
|
+
* (rule own-data-only lets an app touch these and no other archetype's). A key has no colon, so that a key for every
|
|
133
|
+
* app is never taken for an archetype's.
|
|
134
|
+
*/
|
|
135
|
+
export function createSettings(app: string, archetype?: string): Settings {
|
|
136
|
+
const stores: [SettingScope, DeviceStorage | undefined][] = [
|
|
137
|
+
["app", prefixed(`lernapps:${app}:settings:`, `lernapps:${app}:`, STORAGE_QUOTA)],
|
|
138
|
+
[
|
|
139
|
+
"archetype",
|
|
140
|
+
archetype === undefined
|
|
141
|
+
? undefined
|
|
142
|
+
: prefixed(`${SETTINGS_PREFIX}${archetype}:`, SETTINGS_PREFIX, SETTINGS_QUOTA),
|
|
143
|
+
],
|
|
144
|
+
["all", prefixed(SETTINGS_PREFIX, SETTINGS_PREFIX, SETTINGS_QUOTA)],
|
|
145
|
+
];
|
|
146
|
+
const valid = (key: string) => key !== "" && !key.includes(":");
|
|
147
|
+
return {
|
|
148
|
+
load(key, guard) {
|
|
149
|
+
if (!valid(key)) return undefined;
|
|
150
|
+
for (const [scope, store] of stores) {
|
|
151
|
+
const value = store?.load(key, guard);
|
|
152
|
+
if (value !== undefined) return { value, scope };
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
},
|
|
156
|
+
save(key, value, scope) {
|
|
157
|
+
const at = stores.findIndex(([where]) => where === scope);
|
|
158
|
+
const store = stores[at]?.[1];
|
|
159
|
+
if (!valid(key) || store === undefined || !store.save(key, value)) return false;
|
|
160
|
+
for (const [, narrower] of stores.slice(0, at)) narrower?.remove(key);
|
|
161
|
+
return true;
|
|
162
|
+
},
|
|
163
|
+
remove(key, scope) {
|
|
164
|
+
if (valid(key)) stores.find(([where]) => where === scope)?.[1]?.remove(key);
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2023",
|
|
4
|
+
"lib": ["es2023", "dom", "dom.iterable"],
|
|
5
|
+
"module": "esnext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"types": ["vite-plus/client"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"exactOptionalPropertyTypes": true,
|
|
11
|
+
"noImplicitOverride": true,
|
|
12
|
+
"noImplicitReturns": true,
|
|
13
|
+
"noFallthroughCasesInSwitch": true,
|
|
14
|
+
"noUnusedLocals": true,
|
|
15
|
+
"noUnusedParameters": true,
|
|
16
|
+
"noPropertyAccessFromIndexSignature": true,
|
|
17
|
+
"useUnknownInCatchVariables": true,
|
|
18
|
+
"allowJs": false,
|
|
19
|
+
"erasableSyntaxOnly": true,
|
|
20
|
+
"verbatimModuleSyntax": true,
|
|
21
|
+
"allowImportingTsExtensions": true,
|
|
22
|
+
"resolveJsonModule": true,
|
|
23
|
+
"noEmit": true,
|
|
24
|
+
"skipLibCheck": true,
|
|
25
|
+
"isolatedModules": true
|
|
26
|
+
}
|
|
27
|
+
}
|