tessera-learn 0.0.13 → 0.2.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/AGENTS.md +1794 -0
- package/README.md +5 -5
- package/dist/{validation-B-xTvM9B.js → audit-BA5o0ick.js} +605 -269
- package/dist/audit-BA5o0ick.js.map +1 -0
- package/dist/build-commands-C0OnV-Vg.js +27 -0
- package/dist/build-commands-C0OnV-Vg.js.map +1 -0
- package/dist/inline-config-CroQ-_2Y.js +31 -0
- package/dist/inline-config-CroQ-_2Y.js.map +1 -0
- package/dist/plugin/cli.d.ts +9 -1
- package/dist/plugin/cli.d.ts.map +1 -0
- package/dist/plugin/cli.js +326 -17
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +2 -763
- package/dist/plugin-W_rk3Pit.js +731 -0
- package/dist/plugin-W_rk3Pit.js.map +1 -0
- package/package.json +21 -9
- package/src/components/FillInTheBlank.svelte +2 -2
- package/src/components/Matching.svelte +2 -2
- package/src/components/MultipleChoice.svelte +2 -2
- package/src/components/RevealModal.svelte +48 -103
- package/src/components/Sorting.svelte +2 -2
- package/src/components/util.ts +9 -0
- package/src/plugin/a11y/audit.ts +40 -8
- package/src/plugin/a11y-cli.ts +39 -22
- package/src/plugin/ast.ts +276 -0
- package/src/plugin/build-commands.ts +31 -0
- package/src/plugin/cli.ts +96 -21
- package/src/plugin/course-root.ts +98 -0
- package/src/plugin/duplicate-cli.ts +74 -0
- package/src/plugin/index.ts +87 -122
- package/src/plugin/inline-config.ts +54 -0
- package/src/plugin/manifest.ts +103 -136
- package/src/plugin/new-cli.ts +51 -0
- package/src/plugin/package-root.ts +24 -0
- package/src/plugin/project-name.ts +29 -0
- package/src/plugin/quiz.ts +8 -9
- package/src/plugin/template-copy.ts +43 -0
- package/src/plugin/validate-cli.ts +30 -0
- package/src/plugin/validation.ts +152 -244
- package/src/runtime/App.svelte +11 -97
- package/src/runtime/Sidebar.svelte +3 -1
- package/src/runtime/adapters/cmi5.ts +6 -10
- package/src/runtime/adapters/format.ts +6 -0
- package/src/runtime/adapters/retry.ts +1 -1
- package/src/runtime/adapters/scorm2004.ts +2 -4
- package/src/runtime/branding.ts +90 -0
- package/src/runtime/defaults.ts +3 -0
- package/src/runtime/hooks.svelte.ts +16 -53
- package/src/runtime/interaction-format.ts +3 -8
- package/src/runtime/progress.svelte.ts +47 -83
- package/src/runtime/xapi/derive-actor.ts +41 -48
- package/src/runtime/xapi/publisher.ts +14 -14
- package/src/runtime/xapi/setup.ts +39 -46
- package/templates/course/course.config.js +11 -0
- package/templates/course/layout.svelte +116 -0
- package/templates/course/pages/01-getting-started/01-welcome/_meta.js +1 -0
- package/templates/course/pages/01-getting-started/01-welcome/welcome.svelte +19 -0
- package/templates/course/pages/01-getting-started/_meta.js +1 -0
- package/templates/course/styles/custom.css +5 -0
- package/dist/audit-BBJpQGqb.js +0 -204
- package/dist/audit-BBJpQGqb.js.map +0 -1
- package/dist/plugin/a11y-cli.d.ts +0 -1
- package/dist/plugin/a11y-cli.js +0 -36
- package/dist/plugin/a11y-cli.js.map +0 -1
- package/dist/plugin/index.js.map +0 -1
- package/dist/validation-B-xTvM9B.js.map +0 -1
package/src/plugin/a11y-cli.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { runAudit, type ImpactLevel } from './a11y/audit.js';
|
|
1
|
+
import { runAudit, type AuditOptions, type ImpactLevel } from './a11y/audit.js';
|
|
3
2
|
|
|
4
3
|
const VALID_THRESHOLDS: ImpactLevel[] = [
|
|
5
4
|
'minor',
|
|
@@ -8,28 +7,46 @@ const VALID_THRESHOLDS: ImpactLevel[] = [
|
|
|
8
7
|
'critical',
|
|
9
8
|
];
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
export type ParsedA11yArgs =
|
|
11
|
+
| { ok: true; args: AuditOptions }
|
|
12
|
+
| { ok: false; error: string };
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
14
|
+
export function parseA11yArgs(argv: string[]): ParsedA11yArgs {
|
|
15
|
+
let threshold: ImpactLevel | undefined;
|
|
16
|
+
let rebuild = false;
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < argv.length; i++) {
|
|
19
|
+
const arg = argv[i];
|
|
20
|
+
if (arg === '--threshold') {
|
|
21
|
+
const value = argv[++i] as ImpactLevel;
|
|
22
|
+
if (!VALID_THRESHOLDS.includes(value)) {
|
|
23
|
+
return {
|
|
24
|
+
ok: false,
|
|
25
|
+
error: `--threshold must be one of: ${VALID_THRESHOLDS.join(', ')}`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
threshold = value;
|
|
29
|
+
} else if (arg === '--build') {
|
|
30
|
+
rebuild = true;
|
|
31
|
+
} else {
|
|
32
|
+
return { ok: false, error: `Unknown argument: ${arg}` };
|
|
24
33
|
}
|
|
25
|
-
threshold = value;
|
|
26
|
-
} else if (arg === '--build') {
|
|
27
|
-
rebuild = true;
|
|
28
|
-
} else {
|
|
29
|
-
console.error(`[tessera a11y] Unknown argument: ${arg}`);
|
|
30
|
-
process.exit(1);
|
|
31
34
|
}
|
|
35
|
+
|
|
36
|
+
const args: AuditOptions = { rebuild };
|
|
37
|
+
if (threshold !== undefined) args.threshold = threshold;
|
|
38
|
+
return { ok: true, args };
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
export async function runA11y(
|
|
42
|
+
projectRoot: string,
|
|
43
|
+
workspaceRoot: string,
|
|
44
|
+
argv: string[],
|
|
45
|
+
): Promise<number> {
|
|
46
|
+
const parsed = parseA11yArgs(argv);
|
|
47
|
+
if (!parsed.ok) {
|
|
48
|
+
console.error(`[tessera a11y] ${parsed.error}`);
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
return runAudit(projectRoot, workspaceRoot, parsed.args);
|
|
52
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { Parser } from 'acorn';
|
|
2
|
+
import { tsPlugin } from '@sveltejs/acorn-typescript';
|
|
3
|
+
import { parse } from 'svelte/compiler';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared parsing layer for the build-time validator and manifest generator.
|
|
7
|
+
*
|
|
8
|
+
* `.svelte` files go through `svelte/compiler`'s `parse`; plain JS files
|
|
9
|
+
* (`course.config.js`, `_meta.js`) and the module-script fallback go through
|
|
10
|
+
* acorn (with `acorn-typescript` for `as const` / `satisfies T`). Static
|
|
11
|
+
* *values* are still recovered with JSON5 by the callers — only structure
|
|
12
|
+
* parsing lives here.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type PropValue =
|
|
16
|
+
| { kind: 'string'; value: string }
|
|
17
|
+
| { kind: 'expr'; raw: string }
|
|
18
|
+
| { kind: 'bool' };
|
|
19
|
+
|
|
20
|
+
export interface ComponentMatch {
|
|
21
|
+
name: string;
|
|
22
|
+
props: Map<string, PropValue>;
|
|
23
|
+
hasSpread: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type NamedObjectLiteral =
|
|
27
|
+
| { kind: 'none' }
|
|
28
|
+
| { kind: 'invalid' }
|
|
29
|
+
| { kind: 'literal'; text: string };
|
|
30
|
+
|
|
31
|
+
interface Node {
|
|
32
|
+
type: string;
|
|
33
|
+
start: number;
|
|
34
|
+
end: number;
|
|
35
|
+
[key: string]: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface CacheEntry {
|
|
39
|
+
root: Node | null;
|
|
40
|
+
error: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const rootCache = new Map<string, CacheEntry>();
|
|
44
|
+
|
|
45
|
+
/** Drop every cached root. Call at the start of a run to scope the cache. */
|
|
46
|
+
export function clearParseCache(): void {
|
|
47
|
+
rootCache.clear();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseRoot(source: string): CacheEntry {
|
|
51
|
+
const cached = rootCache.get(source);
|
|
52
|
+
if (cached !== undefined) return cached;
|
|
53
|
+
let entry: CacheEntry;
|
|
54
|
+
try {
|
|
55
|
+
entry = {
|
|
56
|
+
root: parse(source, { modern: true }) as unknown as Node,
|
|
57
|
+
error: null,
|
|
58
|
+
};
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
61
|
+
const firstLine = message.split('\n')[0].trim();
|
|
62
|
+
entry = { root: null, error: firstLine || 'parse error' };
|
|
63
|
+
}
|
|
64
|
+
rootCache.set(source, entry);
|
|
65
|
+
return entry;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function collectComponents(root: Node, names: ReadonlySet<string>): Node[] {
|
|
69
|
+
const found: Node[] = [];
|
|
70
|
+
const seen = new Set<object>();
|
|
71
|
+
const walk = (value: unknown): void => {
|
|
72
|
+
if (!value || typeof value !== 'object') return;
|
|
73
|
+
if (seen.has(value)) return;
|
|
74
|
+
seen.add(value);
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
for (const item of value) walk(item);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const node = value as Node;
|
|
80
|
+
if (node.type === 'Component' && names.has(node.name as string)) {
|
|
81
|
+
found.push(node);
|
|
82
|
+
}
|
|
83
|
+
for (const key of Object.keys(node)) {
|
|
84
|
+
if (key === 'type') continue;
|
|
85
|
+
walk(node[key]);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
walk(root);
|
|
89
|
+
return found.sort((a, b) => a.start - b.start);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readProps(source: string, node: Node): ComponentMatch {
|
|
93
|
+
const props = new Map<string, PropValue>();
|
|
94
|
+
let hasSpread = false;
|
|
95
|
+
const attributes = (node.attributes as Node[]) ?? [];
|
|
96
|
+
for (const attr of attributes) {
|
|
97
|
+
if (attr.type === 'SpreadAttribute') {
|
|
98
|
+
hasSpread = true;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (attr.type === 'BindDirective') {
|
|
102
|
+
const expr = (attr as { expression?: Node }).expression;
|
|
103
|
+
if (expr) {
|
|
104
|
+
props.set(attr.name as string, {
|
|
105
|
+
kind: 'expr',
|
|
106
|
+
raw: source.slice(expr.start, expr.end).trim(),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (attr.type !== 'Attribute') continue;
|
|
112
|
+
const name = attr.name as string;
|
|
113
|
+
const value = attr.value;
|
|
114
|
+
if (value === true) {
|
|
115
|
+
props.set(name, { kind: 'bool' });
|
|
116
|
+
} else if (Array.isArray(value)) {
|
|
117
|
+
if (value.length === 0) {
|
|
118
|
+
props.set(name, { kind: 'string', value: '' });
|
|
119
|
+
} else {
|
|
120
|
+
const first = value[0] as Node;
|
|
121
|
+
const last = value[value.length - 1] as Node;
|
|
122
|
+
props.set(name, {
|
|
123
|
+
kind: 'string',
|
|
124
|
+
value: source.slice(first.start, last.end),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
} else if (
|
|
128
|
+
value &&
|
|
129
|
+
typeof value === 'object' &&
|
|
130
|
+
(value as Node).type === 'ExpressionTag'
|
|
131
|
+
) {
|
|
132
|
+
const expr = (value as { expression: Node }).expression;
|
|
133
|
+
props.set(name, {
|
|
134
|
+
kind: 'expr',
|
|
135
|
+
raw: source.slice(expr.start, expr.end).trim(),
|
|
136
|
+
});
|
|
137
|
+
if (source[attr.start] === '{') hasSpread = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { name: node.name as string, props, hasSpread };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Return a one-line message if `source` is not valid Svelte, else null. Lets
|
|
145
|
+
* the validator surface a real syntax error itself rather than only failing
|
|
146
|
+
* later in the compiler (and the compile-less CLI would otherwise miss it).
|
|
147
|
+
*/
|
|
148
|
+
export function getParseError(source: string): string | null {
|
|
149
|
+
return parseRoot(source).error;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Find every question/media component in a `.svelte` source, anywhere in the
|
|
154
|
+
* markup, with its props. Returns null if the source can't be parsed — callers
|
|
155
|
+
* then skip component validation, matching the old "skip when unsure" stance.
|
|
156
|
+
*/
|
|
157
|
+
export function findComponents(
|
|
158
|
+
source: string,
|
|
159
|
+
names: ReadonlySet<string>,
|
|
160
|
+
): ComponentMatch[] | null {
|
|
161
|
+
const { root } = parseRoot(source);
|
|
162
|
+
if (!root) return null;
|
|
163
|
+
return collectComponents(root, names).map((node) => readProps(source, node));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const TsParser = Parser.extend(
|
|
167
|
+
tsPlugin() as unknown as Parameters<typeof Parser.extend>[0],
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
function parseJsModule(source: string): Node | null {
|
|
171
|
+
try {
|
|
172
|
+
return TsParser.parse(source, {
|
|
173
|
+
ecmaVersion: 'latest',
|
|
174
|
+
sourceType: 'module',
|
|
175
|
+
}) as unknown as Node;
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function unwrapTsCast(node: Node | null): Node | null {
|
|
182
|
+
let current = node;
|
|
183
|
+
while (
|
|
184
|
+
current &&
|
|
185
|
+
(current.type === 'TSAsExpression' ||
|
|
186
|
+
current.type === 'TSSatisfiesExpression' ||
|
|
187
|
+
current.type === 'TSTypeAssertion' ||
|
|
188
|
+
current.type === 'TSNonNullExpression')
|
|
189
|
+
) {
|
|
190
|
+
current = (current as { expression?: Node }).expression ?? null;
|
|
191
|
+
}
|
|
192
|
+
return current;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function findPageConfigInProgram(
|
|
196
|
+
program: Node,
|
|
197
|
+
source: string,
|
|
198
|
+
): NamedObjectLiteral {
|
|
199
|
+
const body = (program.body as Node[]) ?? [];
|
|
200
|
+
for (const node of body) {
|
|
201
|
+
if (node.type !== 'ExportNamedDeclaration') continue;
|
|
202
|
+
const declaration = node.declaration as Node | null;
|
|
203
|
+
if (!declaration || declaration.type !== 'VariableDeclaration') continue;
|
|
204
|
+
for (const decl of declaration.declarations as Node[]) {
|
|
205
|
+
const id = decl.id as Node;
|
|
206
|
+
if (id.type !== 'Identifier' || id.name !== 'pageConfig') continue;
|
|
207
|
+
const init = unwrapTsCast(decl.init as Node | null);
|
|
208
|
+
if (init && init.type === 'ObjectExpression') {
|
|
209
|
+
return { kind: 'literal', text: source.slice(init.start, init.end) };
|
|
210
|
+
}
|
|
211
|
+
return { kind: 'invalid' };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { kind: 'none' };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Locate the `export default { ... }` object literal in a plain JS source.
|
|
219
|
+
* Returns a discriminated result so callers can tell parse failure from a
|
|
220
|
+
* missing or non-literal default export.
|
|
221
|
+
*/
|
|
222
|
+
export function defaultExportObjectLiteral(
|
|
223
|
+
jsSource: string,
|
|
224
|
+
): NamedObjectLiteral | { kind: 'parse-error' } {
|
|
225
|
+
const program = parseJsModule(jsSource);
|
|
226
|
+
if (!program) return { kind: 'parse-error' };
|
|
227
|
+
for (const node of (program.body as Node[]) ?? []) {
|
|
228
|
+
if (node.type !== 'ExportDefaultDeclaration') continue;
|
|
229
|
+
const decl = unwrapTsCast(
|
|
230
|
+
(node as { declaration?: Node }).declaration ?? null,
|
|
231
|
+
);
|
|
232
|
+
if (decl && decl.type === 'ObjectExpression') {
|
|
233
|
+
return { kind: 'literal', text: jsSource.slice(decl.start, decl.end) };
|
|
234
|
+
}
|
|
235
|
+
return { kind: 'invalid' };
|
|
236
|
+
}
|
|
237
|
+
return { kind: 'none' };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const MODULE_SCRIPT_OPEN_RE =
|
|
241
|
+
/<script\s+(?:context\s*=\s*["']module["']|module)[^>]*>/;
|
|
242
|
+
const SCRIPT_CLOSE = '</script>';
|
|
243
|
+
|
|
244
|
+
function pageConfigFromModuleScriptFallback(
|
|
245
|
+
svelteSource: string,
|
|
246
|
+
): NamedObjectLiteral {
|
|
247
|
+
const open = svelteSource.match(MODULE_SCRIPT_OPEN_RE);
|
|
248
|
+
if (!open || open.index === undefined) return { kind: 'none' };
|
|
249
|
+
const bodyStart = open.index + open[0].length;
|
|
250
|
+
// Try every `</script>` candidate from earliest; the first one whose body
|
|
251
|
+
// parses as JS is the real close (an earlier hit is inside a string literal).
|
|
252
|
+
let from = bodyStart;
|
|
253
|
+
while (true) {
|
|
254
|
+
const closeIdx = svelteSource.indexOf(SCRIPT_CLOSE, from);
|
|
255
|
+
if (closeIdx < 0) return { kind: 'none' };
|
|
256
|
+
const body = svelteSource.slice(bodyStart, closeIdx);
|
|
257
|
+
const program = parseJsModule(body);
|
|
258
|
+
if (program) return findPageConfigInProgram(program, body);
|
|
259
|
+
from = closeIdx + SCRIPT_CLOSE.length;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Locate `export const pageConfig = { ... }` in a Svelte page's module script
|
|
265
|
+
* and return the object-literal text. Walks the page-level AST so TypeScript
|
|
266
|
+
* (`lang="ts"`) module scripts are handled by Svelte's own parser.
|
|
267
|
+
*/
|
|
268
|
+
export function pageConfigLiteral(svelteSource: string): NamedObjectLiteral {
|
|
269
|
+
const { root } = parseRoot(svelteSource);
|
|
270
|
+
if (root) {
|
|
271
|
+
const program = (root.module as { content?: Node } | null)?.content;
|
|
272
|
+
if (!program) return { kind: 'none' };
|
|
273
|
+
return findPageConfigInProgram(program, svelteSource);
|
|
274
|
+
}
|
|
275
|
+
return pageConfigFromModuleScriptFallback(svelteSource);
|
|
276
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { resolveTesseraConfig } from './inline-config.js';
|
|
2
|
+
|
|
3
|
+
export async function runDev(
|
|
4
|
+
projectRoot: string,
|
|
5
|
+
workspaceRoot: string,
|
|
6
|
+
): Promise<number> {
|
|
7
|
+
const vite = await import('vite');
|
|
8
|
+
const config = await resolveTesseraConfig(projectRoot, workspaceRoot, {
|
|
9
|
+
command: 'serve',
|
|
10
|
+
mode: 'development',
|
|
11
|
+
});
|
|
12
|
+
const server = await vite.createServer(config);
|
|
13
|
+
await server.listen();
|
|
14
|
+
server.printUrls();
|
|
15
|
+
server.bindCLIShortcuts({ print: true });
|
|
16
|
+
// Never resolve: the CLI wrapper would process.exit and kill the server.
|
|
17
|
+
return new Promise<number>(() => {});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function runBuild(
|
|
21
|
+
projectRoot: string,
|
|
22
|
+
workspaceRoot: string,
|
|
23
|
+
): Promise<number> {
|
|
24
|
+
const vite = await import('vite');
|
|
25
|
+
const config = await resolveTesseraConfig(projectRoot, workspaceRoot, {
|
|
26
|
+
command: 'build',
|
|
27
|
+
mode: 'production',
|
|
28
|
+
});
|
|
29
|
+
await vite.build(config);
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
package/src/plugin/cli.ts
CHANGED
|
@@ -1,28 +1,103 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { runValidate } from './validate-cli.js';
|
|
3
|
+
import { runA11y } from './a11y-cli.js';
|
|
4
|
+
import { runNew } from './new-cli.js';
|
|
5
|
+
import { runDuplicate } from './duplicate-cli.js';
|
|
6
|
+
import { resolveCourse } from './course-root.js';
|
|
3
7
|
|
|
4
|
-
const
|
|
5
|
-
const { errors, warnings } = validateProject(projectRoot);
|
|
8
|
+
const USAGE = `Usage: tessera <command> [course] [options]
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
Commands:
|
|
11
|
+
new <name> Scaffold a new course into courses/<name>
|
|
12
|
+
duplicate <source> <new> Copy courses/<source> to courses/<new>
|
|
13
|
+
dev [course] Start the Vite dev server
|
|
14
|
+
export [course] Build and package the course for its LMS standard
|
|
15
|
+
validate [course] Fast static structure checks
|
|
16
|
+
a11y [course] Runtime accessibility audit (builds + drives Playwright)
|
|
17
|
+
check [course] Run validate, then a11y
|
|
8
18
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
Run a command from inside a course folder, or name the course explicitly.
|
|
20
|
+
|
|
21
|
+
a11y/check options:
|
|
22
|
+
--threshold <minor|moderate|serious|critical> Failing impact (default: serious)
|
|
23
|
+
--build Force a fresh build first`;
|
|
24
|
+
|
|
25
|
+
// The course is a leading positional: `tessera <cmd> [course] [flags]`. Only the
|
|
26
|
+
// first token can be the course, and only when it isn't a flag — otherwise a flag
|
|
27
|
+
// value (e.g. the `serious` in `--threshold serious`) would be misread as a name.
|
|
28
|
+
export function splitCourseArg(rest: string[]): {
|
|
29
|
+
course?: string;
|
|
30
|
+
flags: string[];
|
|
31
|
+
} {
|
|
32
|
+
if (rest.length > 0 && !rest[0].startsWith('-')) {
|
|
33
|
+
return { course: rest[0], flags: rest.slice(1) };
|
|
34
|
+
}
|
|
35
|
+
return { course: undefined, flags: rest };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function main(
|
|
39
|
+
argv: string[],
|
|
40
|
+
cwd: string = process.cwd(),
|
|
41
|
+
): Promise<number> {
|
|
42
|
+
const [sub, ...rest] = argv;
|
|
43
|
+
|
|
44
|
+
if (sub === 'new') return runNew(rest[0], cwd);
|
|
45
|
+
if (sub === 'duplicate') return runDuplicate(rest[0], rest[1], cwd);
|
|
46
|
+
|
|
47
|
+
switch (sub) {
|
|
48
|
+
case 'dev':
|
|
49
|
+
case 'export':
|
|
50
|
+
case 'validate':
|
|
51
|
+
case 'a11y':
|
|
52
|
+
case 'check': {
|
|
53
|
+
if (rest.includes('--help') || rest.includes('-h')) {
|
|
54
|
+
console.log(USAGE);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
const { course, flags } = splitCourseArg(rest);
|
|
58
|
+
const resolved = resolveCourse(cwd, course);
|
|
59
|
+
if (!resolved.ok) {
|
|
60
|
+
console.error(`[tessera] ${resolved.error}`);
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
const { courseRoot, workspaceRoot } = resolved;
|
|
64
|
+
|
|
65
|
+
switch (sub) {
|
|
66
|
+
case 'dev': {
|
|
67
|
+
const { runDev } = await import('./build-commands.js');
|
|
68
|
+
return runDev(courseRoot, workspaceRoot);
|
|
69
|
+
}
|
|
70
|
+
case 'export': {
|
|
71
|
+
const { runBuild } = await import('./build-commands.js');
|
|
72
|
+
return runBuild(courseRoot, workspaceRoot);
|
|
73
|
+
}
|
|
74
|
+
case 'validate':
|
|
75
|
+
return runValidate(courseRoot);
|
|
76
|
+
case 'check': {
|
|
77
|
+
const validateCode = runValidate(courseRoot);
|
|
78
|
+
if (validateCode !== 0) return validateCode;
|
|
79
|
+
return runA11y(courseRoot, workspaceRoot, flags);
|
|
80
|
+
}
|
|
81
|
+
case 'a11y':
|
|
82
|
+
return runA11y(courseRoot, workspaceRoot, flags);
|
|
83
|
+
}
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
case '--help':
|
|
87
|
+
case '-h':
|
|
88
|
+
console.log(USAGE);
|
|
89
|
+
return 0;
|
|
90
|
+
case undefined:
|
|
91
|
+
console.error(`No command given.\n\n${USAGE}`);
|
|
92
|
+
return 1;
|
|
93
|
+
default:
|
|
94
|
+
console.error(`Unknown command: ${sub}\n\n${USAGE}`);
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
16
97
|
}
|
|
17
98
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
);
|
|
22
|
-
} else {
|
|
23
|
-
console.log('\x1b[32m[tessera]\x1b[0m Validation passed — no issues found.');
|
|
99
|
+
// import.meta.main is true only when this module is the program entry point,
|
|
100
|
+
// and resolves symlinks itself (pnpm/npm bin shims) — Node >= 24.
|
|
101
|
+
if (import.meta.main) {
|
|
102
|
+
void main(process.argv.slice(2)).then((code) => process.exit(code));
|
|
24
103
|
}
|
|
25
|
-
console.log(
|
|
26
|
-
'\x1b[2m[tessera] Static checks only. For a full runtime accessibility audit, run: npm run accessibility-check\x1b[0m',
|
|
27
|
-
);
|
|
28
|
-
process.exit(0);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { validateProjectName } from './project-name.js';
|
|
4
|
+
|
|
5
|
+
export interface ResolvedCourse {
|
|
6
|
+
ok: true;
|
|
7
|
+
courseRoot: string;
|
|
8
|
+
workspaceRoot: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type ResolveResult = ResolvedCourse | { ok: false; error: string };
|
|
12
|
+
|
|
13
|
+
function isDir(path: string): boolean {
|
|
14
|
+
try {
|
|
15
|
+
return statSync(path).isDirectory();
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isCourse(dir: string): boolean {
|
|
22
|
+
return existsSync(join(dir, 'course.config.js'));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A workspace is the nearest ancestor holding a courses/ directory. The walk
|
|
26
|
+
// includes the starting dir, so the workspace root resolves to itself.
|
|
27
|
+
export function findWorkspaceRoot(cwd: string): string | null {
|
|
28
|
+
for (let dir = resolve(cwd); ; dir = dirname(dir)) {
|
|
29
|
+
if (isDir(join(dir, 'courses'))) return dir;
|
|
30
|
+
const parent = dirname(dir);
|
|
31
|
+
if (parent === dir) return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function listCourses(workspaceRoot: string): string[] {
|
|
36
|
+
const coursesDir = join(workspaceRoot, 'courses');
|
|
37
|
+
try {
|
|
38
|
+
return readdirSync(coursesDir, { withFileTypes: true })
|
|
39
|
+
.filter((e) => e.isDirectory() && isCourse(join(coursesDir, e.name)))
|
|
40
|
+
.map((e) => e.name)
|
|
41
|
+
.sort();
|
|
42
|
+
} catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const NOT_A_WORKSPACE =
|
|
48
|
+
'Not inside a Tessera workspace — no `courses/` directory was found at or above the current directory.';
|
|
49
|
+
|
|
50
|
+
function listHint(workspaceRoot: string): string {
|
|
51
|
+
const courses = listCourses(workspaceRoot);
|
|
52
|
+
if (courses.length === 0) {
|
|
53
|
+
return '\nNo courses found. Create one with `tessera new <name>`.';
|
|
54
|
+
}
|
|
55
|
+
return (
|
|
56
|
+
`\nAvailable courses:\n${courses.map((c) => ` ${c}`).join('\n')}` +
|
|
57
|
+
'\nName one (`tessera <command> <course>`) or cd into its folder.'
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A name argument always wins; otherwise the cwd must itself be a course. There
|
|
62
|
+
// is deliberately no "single course → use it implicitly" rule, so a bare command
|
|
63
|
+
// never changes meaning when a second course is added.
|
|
64
|
+
export function resolveCourse(cwd: string, name?: string): ResolveResult {
|
|
65
|
+
const here = resolve(cwd);
|
|
66
|
+
|
|
67
|
+
if (name) {
|
|
68
|
+
const nameError = validateProjectName(name, 'the name');
|
|
69
|
+
if (nameError) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
error: `Invalid course name "${name}" — ${nameError}.`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const workspaceRoot = findWorkspaceRoot(here);
|
|
76
|
+
if (!workspaceRoot) return { ok: false, error: NOT_A_WORKSPACE };
|
|
77
|
+
const courseRoot = join(workspaceRoot, 'courses', name);
|
|
78
|
+
if (!isCourse(courseRoot)) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
error: `Course "${name}" not found in courses/.${listHint(workspaceRoot)}`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return { ok: true, courseRoot, workspaceRoot };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (isCourse(here)) {
|
|
88
|
+
const workspaceRoot = findWorkspaceRoot(here) ?? dirname(dirname(here));
|
|
89
|
+
return { ok: true, courseRoot: here, workspaceRoot };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const workspaceRoot = findWorkspaceRoot(here);
|
|
93
|
+
if (!workspaceRoot) return { ok: false, error: NOT_A_WORKSPACE };
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: `No course specified.${listHint(workspaceRoot)}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { cpSync, existsSync } from 'node:fs';
|
|
2
|
+
import { basename, join, relative } from 'node:path';
|
|
3
|
+
import { resolveCourse } from './course-root.js';
|
|
4
|
+
import { validateProjectName } from './project-name.js';
|
|
5
|
+
|
|
6
|
+
const HELP =
|
|
7
|
+
'Usage: tessera duplicate <source> <new>\n\n' +
|
|
8
|
+
'Copy courses/<source>/ to courses/<new>/ within the current workspace.';
|
|
9
|
+
|
|
10
|
+
// Generated/build artifacts that should never travel with a verbatim copy. The
|
|
11
|
+
// a11y throwaway build and Vite's cache live under node_modules, so they're
|
|
12
|
+
// already pruned by the node_modules skip; the rest are belt-and-suspenders.
|
|
13
|
+
const SKIP = new Set(['node_modules', 'dist', 'a11y-report.json', '.vite']);
|
|
14
|
+
|
|
15
|
+
function skip(srcPath: string): boolean {
|
|
16
|
+
const name = basename(srcPath);
|
|
17
|
+
return SKIP.has(name) || name.startsWith('.tessera');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// `tessera duplicate <source> <new>` — copy an existing course verbatim within
|
|
21
|
+
// the current workspace. Unlike `new`, there is no template stamping: the JS
|
|
22
|
+
// config (including its title) is copied untouched.
|
|
23
|
+
export function runDuplicate(
|
|
24
|
+
source: string | undefined,
|
|
25
|
+
target: string | undefined,
|
|
26
|
+
cwd: string,
|
|
27
|
+
): number {
|
|
28
|
+
if (
|
|
29
|
+
source === '--help' ||
|
|
30
|
+
source === '-h' ||
|
|
31
|
+
target === '--help' ||
|
|
32
|
+
target === '-h'
|
|
33
|
+
) {
|
|
34
|
+
console.log(HELP);
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
if (!source || !target) {
|
|
38
|
+
console.error('Usage: tessera duplicate <source> <new>');
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const nameError = validateProjectName(target, 'Course name');
|
|
43
|
+
if (nameError) {
|
|
44
|
+
console.error(`[tessera duplicate] ${nameError}`);
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const resolved = resolveCourse(cwd, source);
|
|
49
|
+
if (!resolved.ok) {
|
|
50
|
+
console.error(`[tessera duplicate] ${resolved.error}`);
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
const { courseRoot: srcDir, workspaceRoot } = resolved;
|
|
54
|
+
|
|
55
|
+
const destDir = join(workspaceRoot, 'courses', target);
|
|
56
|
+
if (existsSync(destDir)) {
|
|
57
|
+
console.error(`[tessera duplicate] Course "${target}" already exists.`);
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
cpSync(srcDir, destDir, {
|
|
62
|
+
recursive: true,
|
|
63
|
+
filter: (src) => src === srcDir || !skip(src),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const rel = relative(workspaceRoot, destDir);
|
|
67
|
+
const srcRel = relative(workspaceRoot, srcDir);
|
|
68
|
+
console.log(
|
|
69
|
+
`\nCreated ${rel} (duplicated from ${srcRel}).\n\n` +
|
|
70
|
+
`Remember to update the title in ${rel}/course.config.js.\n\n` +
|
|
71
|
+
`Next steps:\n pnpm tessera dev ${target}\n`,
|
|
72
|
+
);
|
|
73
|
+
return 0;
|
|
74
|
+
}
|