@lernapps/tooling 0.0.1 → 0.1.1
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 +133 -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/dist/i18n.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// archetypes/shared/i18n.ts
|
|
2
|
+
/** The text of `key`, with its placeholders filled; a placeholder without a value stays as it is. */
|
|
3
|
+
function translator(messages, locale = "de-DE") {
|
|
4
|
+
const numbers = new Intl.NumberFormat(locale);
|
|
5
|
+
return (key, params = {}) => (messages[key] ?? key).replace(/\{(\w+)\}/g, (placeholder, name) => {
|
|
6
|
+
const value = params[name];
|
|
7
|
+
if (value === void 0) return placeholder;
|
|
8
|
+
return typeof value === "number" ? numbers.format(value) : value;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export { translator };
|
package/dist/lint.mjs
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, posix, relative } from "node:path";
|
|
3
|
+
// lint/ast.ts
|
|
4
|
+
/** The property name of `a.b` or `a["b"]`; undefined for anything else. */
|
|
5
|
+
function memberName(node) {
|
|
6
|
+
if (node.type !== "MemberExpression") return void 0;
|
|
7
|
+
const key = node.property;
|
|
8
|
+
if (key.type === "Identifier") return node.computed ? void 0 : key.name;
|
|
9
|
+
return key.type === "Literal" && typeof key.value === "string" ? key.value : void 0;
|
|
10
|
+
}
|
|
11
|
+
/** The static text of a string literal or a template (its expressions as `${}`); undefined for anything else. */
|
|
12
|
+
function staticText(node) {
|
|
13
|
+
if (!node) return void 0;
|
|
14
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
15
|
+
if (node.type === "TemplateLiteral") return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join("${}");
|
|
16
|
+
}
|
|
17
|
+
// lint/component.ts
|
|
18
|
+
/**
|
|
19
|
+
* A component's code: a Vue single-file component, or a module next to the components in src/components/ (an
|
|
20
|
+
* exercise's logic module <Name>.ts); not their tests, which run in Node and import the test runner.
|
|
21
|
+
*/
|
|
22
|
+
function isComponentCode(filename) {
|
|
23
|
+
const path = filename.split("\\").join("/");
|
|
24
|
+
if (path.endsWith(".vue")) return true;
|
|
25
|
+
return /(^|\/)src\/components\/[^/]+\.ts$/.test(path) && !path.endsWith(".test.ts");
|
|
26
|
+
}
|
|
27
|
+
/** What a component may import from besides its own folder. */
|
|
28
|
+
const TOOLING_IMPORTS = [
|
|
29
|
+
"@lernapps/tooling/storage",
|
|
30
|
+
"@lernapps/tooling/i18n",
|
|
31
|
+
"@lernapps/tooling/a11y"
|
|
32
|
+
];
|
|
33
|
+
const IMPORTS = `vue, @lernapps/app-templates, ${TOOLING_IMPORTS.join(", ")} and its own folder`;
|
|
34
|
+
/** The globals that make a request. */
|
|
35
|
+
const NETWORK = /* @__PURE__ */ new Set([
|
|
36
|
+
"fetch",
|
|
37
|
+
"XMLHttpRequest",
|
|
38
|
+
"WebSocket",
|
|
39
|
+
"EventSource"
|
|
40
|
+
]);
|
|
41
|
+
/** The globals that exist only in a browser, not while the build renders the component. */
|
|
42
|
+
const BROWSER = /* @__PURE__ */ new Set([
|
|
43
|
+
"window",
|
|
44
|
+
"document",
|
|
45
|
+
"navigator",
|
|
46
|
+
"location",
|
|
47
|
+
"history",
|
|
48
|
+
"localStorage",
|
|
49
|
+
"sessionStorage",
|
|
50
|
+
"indexedDB",
|
|
51
|
+
"matchMedia",
|
|
52
|
+
"requestAnimationFrame",
|
|
53
|
+
"cancelAnimationFrame",
|
|
54
|
+
"getComputedStyle",
|
|
55
|
+
"alert",
|
|
56
|
+
"confirm",
|
|
57
|
+
"prompt",
|
|
58
|
+
"screen",
|
|
59
|
+
"customElements",
|
|
60
|
+
"IntersectionObserver",
|
|
61
|
+
"ResizeObserver",
|
|
62
|
+
"MutationObserver"
|
|
63
|
+
]);
|
|
64
|
+
/** The hooks that never run while the build renders the component: inside them, browser globals are allowed. */
|
|
65
|
+
const BROWSER_HOOKS = /* @__PURE__ */ new Set([
|
|
66
|
+
"onMounted",
|
|
67
|
+
"onBeforeUnmount",
|
|
68
|
+
"onUnmounted"
|
|
69
|
+
]);
|
|
70
|
+
/** The objects through which a global is reached: window.fetch. */
|
|
71
|
+
const GLOBAL_OBJECTS = /* @__PURE__ */ new Set([
|
|
72
|
+
"window",
|
|
73
|
+
"self",
|
|
74
|
+
"globalThis"
|
|
75
|
+
]);
|
|
76
|
+
/** An import's source as written, and the node to report: static imports, re-exports and dynamic imports. */
|
|
77
|
+
function importSource(node) {
|
|
78
|
+
if (node.type === "ImportDeclaration") return node.importKind === "type" ? void 0 : {
|
|
79
|
+
source: node.source.value,
|
|
80
|
+
node
|
|
81
|
+
};
|
|
82
|
+
if (node.type === "ExportNamedDeclaration" || node.type === "ExportAllDeclaration") {
|
|
83
|
+
if (node.source === null || node.exportKind === "type") return void 0;
|
|
84
|
+
return {
|
|
85
|
+
source: node.source.value,
|
|
86
|
+
node
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (node.type === "ImportExpression") {
|
|
90
|
+
const source = node.source;
|
|
91
|
+
return {
|
|
92
|
+
source: source.type === "Literal" && typeof source.value === "string" ? source.value : "(an expression)",
|
|
93
|
+
node
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** A URL as an import's source: a scheme (https:, data:) or protocol-relative. */
|
|
98
|
+
const isUrl = (source) => /^[a-z][a-z0-9+.-]*:|^\/\//i.test(source);
|
|
99
|
+
/**
|
|
100
|
+
* Whether an identifier is a reference to a global of the browser or the runtime: a name the code reads that no
|
|
101
|
+
* scope of the file declares (Oxlint's isGlobalReference knows only the globals it is configured with). A property
|
|
102
|
+
* name (a.fetch, { fetch: … }) is no reference.
|
|
103
|
+
*/
|
|
104
|
+
function isFreeReference(context, node) {
|
|
105
|
+
const parent = node.parent;
|
|
106
|
+
if (parent.type === "MemberExpression" && parent.property === node && !parent.computed) return false;
|
|
107
|
+
if ((parent.type === "Property" || parent.type === "MethodDefinition" || parent.type === "PropertyDefinition") && parent.key === node && !parent.computed && !(parent.type === "Property" && parent.shorthand)) return false;
|
|
108
|
+
for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) if (scope.type !== "global" && scope.set.has(node.name)) return false;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
// lint/rule.ts
|
|
112
|
+
const RULE_PAGE = "https://lernapps.net/tooling/rules/#";
|
|
113
|
+
/** An ESLint-compatible rule with its link in `meta.docs.url` and in every message. */
|
|
114
|
+
const lintRule = ({ id, description, messages, schema, create }) => ({
|
|
115
|
+
meta: {
|
|
116
|
+
type: "problem",
|
|
117
|
+
docs: {
|
|
118
|
+
description,
|
|
119
|
+
url: `${RULE_PAGE}${id}`
|
|
120
|
+
},
|
|
121
|
+
...schema === void 0 ? {} : { schema },
|
|
122
|
+
messages: Object.fromEntries(Object.entries(messages).map(([key, message]) => [key, `${message} ${RULE_PAGE}${id}`]))
|
|
123
|
+
},
|
|
124
|
+
create
|
|
125
|
+
});
|
|
126
|
+
// lint/rules/learner-text-german.ts
|
|
127
|
+
/** Properties of an element (or the document) that put text in front of the learner. */
|
|
128
|
+
const TEXT_PROPERTIES = /* @__PURE__ */ new Set([
|
|
129
|
+
"textContent",
|
|
130
|
+
"innerText",
|
|
131
|
+
"outerText",
|
|
132
|
+
"innerHTML",
|
|
133
|
+
"outerHTML",
|
|
134
|
+
"title",
|
|
135
|
+
"placeholder",
|
|
136
|
+
"alt",
|
|
137
|
+
"label",
|
|
138
|
+
"ariaLabel",
|
|
139
|
+
"ariaDescription",
|
|
140
|
+
"ariaPlaceholder",
|
|
141
|
+
"ariaRoleDescription",
|
|
142
|
+
"ariaValueText"
|
|
143
|
+
]);
|
|
144
|
+
/** Attributes that put text in front of the learner. */
|
|
145
|
+
const TEXT_ATTRIBUTES = /* @__PURE__ */ new Set([
|
|
146
|
+
"title",
|
|
147
|
+
"alt",
|
|
148
|
+
"placeholder",
|
|
149
|
+
"label",
|
|
150
|
+
"aria-label",
|
|
151
|
+
"aria-description",
|
|
152
|
+
"aria-placeholder",
|
|
153
|
+
"aria-roledescription",
|
|
154
|
+
"aria-valuetext"
|
|
155
|
+
]);
|
|
156
|
+
/** Calls that show a text, with the position of the text among their arguments. */
|
|
157
|
+
const TEXT_ARGUMENTS = /* @__PURE__ */ new Map([
|
|
158
|
+
["createTextNode", 0],
|
|
159
|
+
["insertAdjacentText", 1],
|
|
160
|
+
["insertAdjacentHTML", 1],
|
|
161
|
+
["alert", 0],
|
|
162
|
+
["confirm", 0],
|
|
163
|
+
["prompt", 0]
|
|
164
|
+
]);
|
|
165
|
+
/** The text a learner would read: markup, entities and placeholders removed. */
|
|
166
|
+
const visible = (text) => text.replace(/<[^>]*>/g, " ").replace(/&[#\w]+;/g, " ").replace(/\$\{\}/g, " ");
|
|
167
|
+
const hasWords = (text) => /\p{L}{2,}/u.test(visible(text));
|
|
168
|
+
function calleeName(node) {
|
|
169
|
+
const callee = node.callee;
|
|
170
|
+
if (callee.type === "Identifier") return callee.name;
|
|
171
|
+
return memberName(callee);
|
|
172
|
+
}
|
|
173
|
+
var learner_text_german_default = lintRule({
|
|
174
|
+
id: "learner-text-german",
|
|
175
|
+
description: "Learner texts come from the message files, not from strings in code.",
|
|
176
|
+
messages: { found: "Learner text in code (\"{{text}}\"). Put it into the app's message file (src/messages/de.json) and show it with translator from @lernapps/tooling/i18n." },
|
|
177
|
+
create(context) {
|
|
178
|
+
if (isComponentCode(context.filename)) return {};
|
|
179
|
+
const report = (node, text) => {
|
|
180
|
+
const shown = visible(text).replace(/\s+/g, " ").trim();
|
|
181
|
+
context.report({
|
|
182
|
+
node,
|
|
183
|
+
messageId: "found",
|
|
184
|
+
data: { text: shown.length > 40 ? `${shown.slice(0, 39)}…` : shown }
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
return {
|
|
188
|
+
AssignmentExpression(node) {
|
|
189
|
+
const name = memberName(node.left);
|
|
190
|
+
const text = staticText(node.right);
|
|
191
|
+
if (name !== void 0 && TEXT_PROPERTIES.has(name) && text !== void 0 && hasWords(text)) report(node.right, text);
|
|
192
|
+
},
|
|
193
|
+
CallExpression(node) {
|
|
194
|
+
const name = calleeName(node);
|
|
195
|
+
if (name === void 0) return;
|
|
196
|
+
if (name === "setAttribute") {
|
|
197
|
+
const attribute = staticText(node.arguments[0]);
|
|
198
|
+
const text = staticText(node.arguments[1]);
|
|
199
|
+
if (attribute !== void 0 && TEXT_ATTRIBUTES.has(attribute) && text !== void 0 && hasWords(text)) report(node, text);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const position = TEXT_ARGUMENTS.get(name);
|
|
203
|
+
if (position === void 0) return;
|
|
204
|
+
const argument = node.arguments[position];
|
|
205
|
+
const text = staticText(argument);
|
|
206
|
+
if (argument && text !== void 0 && hasWords(text)) report(argument, text);
|
|
207
|
+
},
|
|
208
|
+
JSXText(node) {
|
|
209
|
+
if (hasWords(node.value)) report(node, node.value);
|
|
210
|
+
},
|
|
211
|
+
JSXAttribute(node) {
|
|
212
|
+
const attribute = node.name.type === "JSXIdentifier" ? node.name.name : void 0;
|
|
213
|
+
const text = staticText(node.value);
|
|
214
|
+
if (attribute !== void 0 && TEXT_ATTRIBUTES.has(attribute) && text !== void 0 && hasWords(text)) report(node, text);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
// lint/rules/no-request-before-click.ts
|
|
220
|
+
/** An absolute or protocol-relative URL and its host. */
|
|
221
|
+
const URL_PATTERN = /(?:\bhttps?:)?\/\/((?:[a-z0-9-]+\.)+[a-z]{2,})(?=[/:?#"'`\s]|$)/gi;
|
|
222
|
+
/** Hosts that are no request of the app: the platform (imprint, privacy notice) and XML namespaces. */
|
|
223
|
+
const ALLOWED = [/(^|\.)lernapps\.net$/i, /^www\.w3\.org$/i];
|
|
224
|
+
function otherHosts(text) {
|
|
225
|
+
return [...text.matchAll(URL_PATTERN)].map((match) => match[1] ?? "").filter((host) => host !== "" && !ALLOWED.some((allowed) => allowed.test(host)));
|
|
226
|
+
}
|
|
227
|
+
var no_request_before_click_default = lintRule({
|
|
228
|
+
id: "no-request-before-click",
|
|
229
|
+
description: "No URL of another host in code: bundle what the app needs; load other servers only after a click.",
|
|
230
|
+
messages: { found: "URL of another host in code ({{host}}). Bundle the file with the app at build time; if the learner clicks to load it, suppress here with that reason (-- loaded after a click)." },
|
|
231
|
+
create(context) {
|
|
232
|
+
const check = (node, text) => {
|
|
233
|
+
for (const host of new Set(otherHosts(text))) context.report({
|
|
234
|
+
node,
|
|
235
|
+
messageId: "found",
|
|
236
|
+
data: { host }
|
|
237
|
+
});
|
|
238
|
+
};
|
|
239
|
+
return {
|
|
240
|
+
Literal(node) {
|
|
241
|
+
if (typeof node.value === "string") check(node, node.value);
|
|
242
|
+
},
|
|
243
|
+
TemplateLiteral(node) {
|
|
244
|
+
check(node, node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join("${}"));
|
|
245
|
+
},
|
|
246
|
+
JSXText(node) {
|
|
247
|
+
check(node, node.value);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
// lint/rules/storage-through-wrapper.ts
|
|
253
|
+
const STORAGE = /* @__PURE__ */ new Set([
|
|
254
|
+
"localStorage",
|
|
255
|
+
"sessionStorage",
|
|
256
|
+
"indexedDB"
|
|
257
|
+
]);
|
|
258
|
+
const GLOBALS = /* @__PURE__ */ new Set([
|
|
259
|
+
"window",
|
|
260
|
+
"globalThis",
|
|
261
|
+
"self"
|
|
262
|
+
]);
|
|
263
|
+
const name = (node) => node.type === "Identifier" ? node.name : void 0;
|
|
264
|
+
var storage_through_wrapper_default = lintRule({
|
|
265
|
+
id: "storage-through-wrapper",
|
|
266
|
+
description: "Store only through createStorage of @lernapps/tooling/storage, on the device; no cookies.",
|
|
267
|
+
messages: {
|
|
268
|
+
storage: "{{what}} used directly. Store through createStorage from @lernapps/tooling/storage: it keeps the data on the device, under the app's prefix, and works when storage is blocked.",
|
|
269
|
+
cookie: "Cookie set or read. Set no cookies; keep what the app must remember on the device through createStorage from @lernapps/tooling/storage."
|
|
270
|
+
},
|
|
271
|
+
create(context) {
|
|
272
|
+
return {
|
|
273
|
+
Identifier(node) {
|
|
274
|
+
if (!STORAGE.has(node.name)) return;
|
|
275
|
+
const parent = node.parent;
|
|
276
|
+
if (parent.type === "MemberExpression" && parent.property === node && !parent.computed) return;
|
|
277
|
+
if (parent.type === "Property" && parent.key === node && !parent.computed && !parent.shorthand) return;
|
|
278
|
+
context.report({
|
|
279
|
+
node,
|
|
280
|
+
messageId: "storage",
|
|
281
|
+
data: { what: node.name }
|
|
282
|
+
});
|
|
283
|
+
},
|
|
284
|
+
MemberExpression(node) {
|
|
285
|
+
const key = memberName(node);
|
|
286
|
+
if (key === void 0) return;
|
|
287
|
+
const object = name(node.object);
|
|
288
|
+
if (object !== void 0 && GLOBALS.has(object) && STORAGE.has(key)) context.report({
|
|
289
|
+
node,
|
|
290
|
+
messageId: "storage",
|
|
291
|
+
data: { what: key }
|
|
292
|
+
});
|
|
293
|
+
else if (object === "document" && key === "cookie") context.report({
|
|
294
|
+
node,
|
|
295
|
+
messageId: "cookie"
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
// lint/rules/suppression-reason.ts
|
|
302
|
+
/** `eslint-disable`, `oxlint-disable`, `-line` and `-next-line`: the rules, then `-- reason`. */
|
|
303
|
+
const DISABLE = /^\s*(?:eslint|oxlint)-disable(?:-next-line|-line)?(?=\s|$)(.*)$/s;
|
|
304
|
+
const TS_DIRECTIVE = /^\s*\/?\s*@ts-(ignore|nocheck|expect-error)\b(.*)$/s;
|
|
305
|
+
var suppression_reason_default = lintRule({
|
|
306
|
+
id: "suppression-reason",
|
|
307
|
+
description: "Every suppression names its rules and gives its reason.",
|
|
308
|
+
messages: {
|
|
309
|
+
rules: "Suppression without rules. Name the rules it turns off: // oxlint-disable-next-line <rule> -- <reason>.",
|
|
310
|
+
reason: "Suppression without a reason. Write why after --: // oxlint-disable-next-line <rule> -- <reason>.",
|
|
311
|
+
typescript: "@ts-{{directive}} hides every type error. Fix the type, or use // @ts-expect-error -- <reason>.",
|
|
312
|
+
expectError: "@ts-expect-error without a reason. Write why after it: // @ts-expect-error -- <reason>."
|
|
313
|
+
},
|
|
314
|
+
create(context) {
|
|
315
|
+
return { Program() {
|
|
316
|
+
for (const comment of context.sourceCode.getAllComments()) {
|
|
317
|
+
const loc = comment.loc;
|
|
318
|
+
const disable = DISABLE.exec(comment.value);
|
|
319
|
+
if (disable) {
|
|
320
|
+
const [rules = "", ...reason] = (disable[1] ?? "").split("--");
|
|
321
|
+
if (rules.trim() === "") context.report({
|
|
322
|
+
loc,
|
|
323
|
+
messageId: "rules"
|
|
324
|
+
});
|
|
325
|
+
else if (reason.join("--").trim() === "") context.report({
|
|
326
|
+
loc,
|
|
327
|
+
messageId: "reason"
|
|
328
|
+
});
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const directive = TS_DIRECTIVE.exec(comment.value);
|
|
332
|
+
if (!directive) continue;
|
|
333
|
+
const [, name = "", rest = ""] = directive;
|
|
334
|
+
if (name !== "expect-error") context.report({
|
|
335
|
+
loc,
|
|
336
|
+
messageId: "typescript",
|
|
337
|
+
data: { directive: name }
|
|
338
|
+
});
|
|
339
|
+
else if (rest.replace(/^[\s:-]+/, "").trim() === "") context.report({
|
|
340
|
+
loc,
|
|
341
|
+
messageId: "expectError"
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
} };
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
// lint/rules/vue-component-browser-globals.ts
|
|
348
|
+
var vue_component_browser_globals_default = lintRule({
|
|
349
|
+
id: "vue-component-browser-globals",
|
|
350
|
+
description: "A component uses browser globals only inside onMounted (and the unmount hooks).",
|
|
351
|
+
messages: { global: "{{name}} outside onMounted; the build renders the component where there is no {{name}}, so use it only inside onMounted." },
|
|
352
|
+
create(context) {
|
|
353
|
+
if (!isComponentCode(context.filename)) return {};
|
|
354
|
+
return { Identifier(node) {
|
|
355
|
+
if (!BROWSER.has(node.name) || !isFreeReference(context, node)) return;
|
|
356
|
+
const stack = [...context.sourceCode.getAncestors(node), node];
|
|
357
|
+
if (!stack.some((ancestor, index) => ancestor.type === "CallExpression" && ancestor.callee.type === "Identifier" && BROWSER_HOOKS.has(ancestor.callee.name) && ancestor.arguments.some((argument) => argument === stack[index + 1]))) context.report({
|
|
358
|
+
node,
|
|
359
|
+
messageId: "global",
|
|
360
|
+
data: { name: node.name }
|
|
361
|
+
});
|
|
362
|
+
} };
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
// lint/rules/vue-component-imports.ts
|
|
366
|
+
const allowed = (source) => source === "vue" || source === "@lernapps/app-templates" || source.startsWith("@lernapps/app-templates/") || TOOLING_IMPORTS.includes(source) || source.startsWith("./") && !source.slice(2).includes("/");
|
|
367
|
+
const SOURCE_IMPORTS = `${IMPORTS}, the app's own files in src/ and the libraries in the dependencies of package.json`;
|
|
368
|
+
/** The libraries in the dependencies of the package.json in `cwd`. */
|
|
369
|
+
function dependencies(cwd) {
|
|
370
|
+
try {
|
|
371
|
+
const manifest = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
|
|
372
|
+
return Object.keys(manifest.dependencies ?? {});
|
|
373
|
+
} catch {
|
|
374
|
+
return [];
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/** Whether `source`, imported in `file`, is one of the app's files in src/ or a library of its dependencies. */
|
|
378
|
+
function inSourceScope(cwd, file, source, libraries) {
|
|
379
|
+
if (source.startsWith("./") || source.startsWith("../")) {
|
|
380
|
+
const from = relative(cwd, dirname(file)).split("\\").join("/");
|
|
381
|
+
return posix.normalize(posix.join(from, source)).startsWith("src/");
|
|
382
|
+
}
|
|
383
|
+
return libraries.some((name) => source === name || source.startsWith(`${name}/`));
|
|
384
|
+
}
|
|
385
|
+
var vue_component_imports_default = lintRule({
|
|
386
|
+
id: "vue-component-imports",
|
|
387
|
+
description: "A component imports only from vue, the runtime, @lernapps/tooling's storage, i18n and a11y, and its own folder.",
|
|
388
|
+
messages: { import: `an import from {{source}}; a component imports only from {{allowed}}.` },
|
|
389
|
+
schema: [{
|
|
390
|
+
type: "object",
|
|
391
|
+
properties: { scope: {
|
|
392
|
+
type: "string",
|
|
393
|
+
enum: ["src"]
|
|
394
|
+
} },
|
|
395
|
+
additionalProperties: false
|
|
396
|
+
}],
|
|
397
|
+
create(context) {
|
|
398
|
+
if (!isComponentCode(context.filename)) return {};
|
|
399
|
+
const sourceScope = context.options[0]?.scope === "src";
|
|
400
|
+
const cwd = context.cwd;
|
|
401
|
+
const libraries = sourceScope ? dependencies(cwd) : [];
|
|
402
|
+
const check = (node) => {
|
|
403
|
+
const found = importSource(node);
|
|
404
|
+
if (found === void 0 || isUrl(found.source) || allowed(found.source)) return;
|
|
405
|
+
if (sourceScope && inSourceScope(cwd, context.filename, found.source, libraries)) return;
|
|
406
|
+
context.report({
|
|
407
|
+
node: found.node,
|
|
408
|
+
messageId: "import",
|
|
409
|
+
data: {
|
|
410
|
+
source: found.source,
|
|
411
|
+
allowed: sourceScope ? SOURCE_IMPORTS : IMPORTS
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
};
|
|
415
|
+
return {
|
|
416
|
+
ImportDeclaration: check,
|
|
417
|
+
ExportNamedDeclaration: check,
|
|
418
|
+
ExportAllDeclaration: check,
|
|
419
|
+
ImportExpression: check
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
// lint/rules/vue-component-no-html-injection.ts
|
|
424
|
+
const HTML = /* @__PURE__ */ new Set([
|
|
425
|
+
"innerHTML",
|
|
426
|
+
"outerHTML",
|
|
427
|
+
"insertAdjacentHTML"
|
|
428
|
+
]);
|
|
429
|
+
// lint/plugin.ts
|
|
430
|
+
const rules = {
|
|
431
|
+
"learner-text-german": learner_text_german_default,
|
|
432
|
+
"no-request-before-click": no_request_before_click_default,
|
|
433
|
+
"storage-through-wrapper": storage_through_wrapper_default,
|
|
434
|
+
"suppression-reason": suppression_reason_default,
|
|
435
|
+
"vue-component-browser-globals": vue_component_browser_globals_default,
|
|
436
|
+
"vue-component-imports": vue_component_imports_default,
|
|
437
|
+
"vue-component-no-html-injection": lintRule({
|
|
438
|
+
id: "vue-component-no-html-injection",
|
|
439
|
+
description: "A component runs no code and writes no HTML from strings: no eval, new Function, innerHTML or v-html.",
|
|
440
|
+
messages: {
|
|
441
|
+
code: "{{name}}: a component runs no code from strings.",
|
|
442
|
+
html: "{{name}}: a component writes no HTML from strings; let the template render it."
|
|
443
|
+
},
|
|
444
|
+
create(context) {
|
|
445
|
+
if (!isComponentCode(context.filename)) return {};
|
|
446
|
+
return {
|
|
447
|
+
Identifier(node) {
|
|
448
|
+
if (node.name !== "eval" && node.name !== "Function") return;
|
|
449
|
+
if (!isFreeReference(context, node)) return;
|
|
450
|
+
const parent = node.parent;
|
|
451
|
+
const called = (parent.type === "NewExpression" || parent.type === "CallExpression") && parent.callee === node;
|
|
452
|
+
if (node.name === "eval" || called) context.report({
|
|
453
|
+
node,
|
|
454
|
+
messageId: "code",
|
|
455
|
+
data: { name: node.name }
|
|
456
|
+
});
|
|
457
|
+
},
|
|
458
|
+
MemberExpression(node) {
|
|
459
|
+
const key = memberName(node);
|
|
460
|
+
if (key !== void 0 && HTML.has(key)) context.report({
|
|
461
|
+
node,
|
|
462
|
+
messageId: "html",
|
|
463
|
+
data: { name: key }
|
|
464
|
+
});
|
|
465
|
+
},
|
|
466
|
+
Property(node) {
|
|
467
|
+
const key = node.key;
|
|
468
|
+
const name = key.type === "Identifier" ? key.name : key.type === "Literal" ? key.value : void 0;
|
|
469
|
+
if (!node.computed && name === "innerHTML") context.report({
|
|
470
|
+
node,
|
|
471
|
+
messageId: "html",
|
|
472
|
+
data: { name }
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
}),
|
|
478
|
+
"vue-component-no-network": lintRule({
|
|
479
|
+
id: "vue-component-no-network",
|
|
480
|
+
description: "A component makes no network request and imports nothing from a URL.",
|
|
481
|
+
messages: {
|
|
482
|
+
request: "{{name}}: a component makes no network request; give it what it needs through its props.",
|
|
483
|
+
beacon: "sendBeacon: a component makes no network request.",
|
|
484
|
+
import: "an import from {{source}}; a component loads nothing from the network, so bundle what it needs."
|
|
485
|
+
},
|
|
486
|
+
create(context) {
|
|
487
|
+
if (!isComponentCode(context.filename)) return {};
|
|
488
|
+
const fromUrl = (node) => {
|
|
489
|
+
const found = importSource(node);
|
|
490
|
+
if (found !== void 0 && isUrl(found.source)) context.report({
|
|
491
|
+
node: found.node,
|
|
492
|
+
messageId: "import",
|
|
493
|
+
data: { source: found.source }
|
|
494
|
+
});
|
|
495
|
+
};
|
|
496
|
+
return {
|
|
497
|
+
ImportDeclaration: fromUrl,
|
|
498
|
+
ExportNamedDeclaration: fromUrl,
|
|
499
|
+
ExportAllDeclaration: fromUrl,
|
|
500
|
+
ImportExpression: fromUrl,
|
|
501
|
+
Identifier(node) {
|
|
502
|
+
if (NETWORK.has(node.name) && isFreeReference(context, node)) context.report({
|
|
503
|
+
node,
|
|
504
|
+
messageId: "request",
|
|
505
|
+
data: { name: node.name }
|
|
506
|
+
});
|
|
507
|
+
},
|
|
508
|
+
MemberExpression(node) {
|
|
509
|
+
const key = memberName(node);
|
|
510
|
+
if (key === "sendBeacon") context.report({
|
|
511
|
+
node,
|
|
512
|
+
messageId: "beacon"
|
|
513
|
+
});
|
|
514
|
+
else if (key !== void 0 && NETWORK.has(key) && node.object.type === "Identifier" && GLOBAL_OBJECTS.has(node.object.name)) context.report({
|
|
515
|
+
node,
|
|
516
|
+
messageId: "request",
|
|
517
|
+
data: { name: key }
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
})
|
|
523
|
+
};
|
|
524
|
+
var plugin_default = {
|
|
525
|
+
meta: { name: "lernapps" },
|
|
526
|
+
rules
|
|
527
|
+
};
|
|
528
|
+
export { plugin_default as default, rules };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
// src/plan.ts
|
|
5
|
+
/** The folder of the plans, relative to the app's root. */
|
|
6
|
+
const PLANS = ".vibe/plans";
|
|
7
|
+
/** The one plan of an app made before the plans had a folder. */
|
|
8
|
+
const LEGACY_PLAN = ".vibe/plan.md";
|
|
9
|
+
/** Where `lernapps update` moves the legacy plan. */
|
|
10
|
+
const MIGRATED_PLAN = `${PLANS}/01-first-version.md`;
|
|
11
|
+
/** A plan's file name: two digits, a hyphen, a short name. */
|
|
12
|
+
const PLAN_NAME = /^(\d{2})-[^/\\]+\.md$/;
|
|
13
|
+
/** The plans in .vibe/plans/ (regular files named <NN>-<short name>.md), relative to `root`, by number. */
|
|
14
|
+
function plans(root = ".") {
|
|
15
|
+
const folder = join(root, PLANS);
|
|
16
|
+
if (!existsSync(folder) || !statSync(folder).isDirectory()) return [];
|
|
17
|
+
return readdirSync(folder).filter((name) => PLAN_NAME.test(name) && statSync(join(folder, name)).isFile()).sort((a, b) => Number(a.slice(0, 2)) - Number(b.slice(0, 2)) || a.localeCompare(b)).map((name) => `${PLANS}/${name}`);
|
|
18
|
+
}
|
|
19
|
+
/** The current plan, relative to `root`: the highest number in .vibe/plans/, else .vibe/plan.md; undefined if none. */
|
|
20
|
+
function currentPlan(root = ".") {
|
|
21
|
+
const numbered = plans(root).at(-1);
|
|
22
|
+
if (numbered !== void 0) return numbered;
|
|
23
|
+
const legacy = join(root, LEGACY_PLAN);
|
|
24
|
+
return existsSync(legacy) && statSync(legacy).isFile() ? LEGACY_PLAN : void 0;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The front matter of a plan file, as an object: empty when it has none or it is not YAML. The hooks read it as the
|
|
28
|
+
* schema guidance/plan-front-matter.v1.schema.json describes it.
|
|
29
|
+
*/
|
|
30
|
+
function planFrontMatter(file) {
|
|
31
|
+
const match = /^---\n([\s\S]*?)\n---\n/.exec(readFileSync(file, "utf8"));
|
|
32
|
+
if (!match) return {};
|
|
33
|
+
try {
|
|
34
|
+
const front = parse(match[1] ?? "");
|
|
35
|
+
return typeof front === "object" && front !== null ? front : {};
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Increments prePushFailures in the front matter of the current plan below `root`, when it has the key. */
|
|
41
|
+
function countPrePushFailure(root = ".") {
|
|
42
|
+
const plan = currentPlan(root);
|
|
43
|
+
if (plan === void 0) return;
|
|
44
|
+
const file = join(root, plan);
|
|
45
|
+
const text = readFileSync(file, "utf8");
|
|
46
|
+
const counted = text.replace(/^(---\n(?:(?!---\n)[^\n]*\n)*?prePushFailures:[ \t]*)(\d+)/, (_match, head, count) => `${head}${Number(count) + 1}`);
|
|
47
|
+
if (counted !== text) writeFileSync(file, counted);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Counts a failed check in failedChecks of the current plan's front matter below `root`, when it has the key: one run
|
|
51
|
+
* more for each of `rules`, the rules with an error in that run. A flow map on one line, the rule ids sorted, so the
|
|
52
|
+
* front matter stays one key per line: `failedChecks: { checks-pass: 2, quiz-bank-valid: 1 }`.
|
|
53
|
+
*/
|
|
54
|
+
function countFailedChecks(rules, root = ".") {
|
|
55
|
+
const plan = currentPlan(root);
|
|
56
|
+
if (plan === void 0 || rules.length === 0) return;
|
|
57
|
+
const file = join(root, plan);
|
|
58
|
+
const text = readFileSync(file, "utf8");
|
|
59
|
+
const line = /^(---\n(?:(?!---\n)[^\n]*\n)*?failedChecks:[ \t]*)([^\n]*)/.exec(text);
|
|
60
|
+
if (line === null) return;
|
|
61
|
+
let counts = {};
|
|
62
|
+
try {
|
|
63
|
+
const value = parse(line[2] ?? "");
|
|
64
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
65
|
+
for (const [rule, runs] of Object.entries(value)) if (typeof runs === "number") counts[rule] = runs;
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
counts = {};
|
|
69
|
+
}
|
|
70
|
+
for (const rule of new Set(rules)) counts[rule] = (counts[rule] ?? 0) + 1;
|
|
71
|
+
const entries = Object.keys(counts).sort().map((rule) => `${rule}: ${counts[rule]}`);
|
|
72
|
+
const head = line[1] ?? "";
|
|
73
|
+
writeFileSync(file, `${head}{ ${entries.join(", ")} }${text.slice(head.length + (line[2] ?? "").length)}`);
|
|
74
|
+
}
|
|
75
|
+
export { countFailedChecks as a, planFrontMatter as c, PLAN_NAME as i, plans as l, MIGRATED_PLAN as n, countPrePushFailure as o, PLANS as r, currentPlan as s, LEGACY_PLAN as t };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
import { defineConfig, devices } from "playwright/test";
|
|
3
|
+
// archetypes/shared/playwright.ts
|
|
4
|
+
/** A port nobody listens on now, so that several apps (or test runs) can check at the same time. */
|
|
5
|
+
const freePort = () => new Promise((resolve, reject) => {
|
|
6
|
+
const server = createServer();
|
|
7
|
+
server.on("error", reject);
|
|
8
|
+
server.listen(0, "127.0.0.1", () => {
|
|
9
|
+
const address = server.address();
|
|
10
|
+
server.close(() => resolve(typeof address === "object" && address !== null ? address.port : 4317));
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
const PORT = process.env["LERNAPPS_PREVIEW_PORT"] ??= String(await freePort());
|
|
14
|
+
const URL = `http://127.0.0.1:${PORT}/`;
|
|
15
|
+
const base = {
|
|
16
|
+
testDir: "e2e",
|
|
17
|
+
testMatch: "*.e2e.ts",
|
|
18
|
+
forbidOnly: true,
|
|
19
|
+
fullyParallel: true,
|
|
20
|
+
workers: Number(process.env["LERNAPPS_PLAYWRIGHT_WORKERS"]) || "75%",
|
|
21
|
+
reporter: [["line"]],
|
|
22
|
+
use: {
|
|
23
|
+
baseURL: URL,
|
|
24
|
+
...process.env["LERNAPPS_CHROMIUM"] ? { launchOptions: { executablePath: process.env["LERNAPPS_CHROMIUM"] } } : {}
|
|
25
|
+
},
|
|
26
|
+
projects: [{
|
|
27
|
+
name: "desktop",
|
|
28
|
+
use: { ...devices["Desktop Chrome"] }
|
|
29
|
+
}, {
|
|
30
|
+
name: "360px",
|
|
31
|
+
use: {
|
|
32
|
+
...devices["Desktop Chrome"],
|
|
33
|
+
viewport: {
|
|
34
|
+
width: 360,
|
|
35
|
+
height: 740
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}],
|
|
39
|
+
webServer: {
|
|
40
|
+
command: `vp preview --host 127.0.0.1 --port ${PORT} --strictPort`,
|
|
41
|
+
url: URL
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
/** The configuration, with the app's own settings over it. */
|
|
45
|
+
const playwright = (config = {}) => defineConfig(base, config);
|
|
46
|
+
var playwright_default = playwright();
|
|
47
|
+
export { playwright_default as default, playwright };
|