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.
Files changed (68) hide show
  1. package/AGENTS.md +1794 -0
  2. package/README.md +5 -5
  3. package/dist/{validation-B-xTvM9B.js → audit-BA5o0ick.js} +605 -269
  4. package/dist/audit-BA5o0ick.js.map +1 -0
  5. package/dist/build-commands-C0OnV-Vg.js +27 -0
  6. package/dist/build-commands-C0OnV-Vg.js.map +1 -0
  7. package/dist/inline-config-CroQ-_2Y.js +31 -0
  8. package/dist/inline-config-CroQ-_2Y.js.map +1 -0
  9. package/dist/plugin/cli.d.ts +9 -1
  10. package/dist/plugin/cli.d.ts.map +1 -0
  11. package/dist/plugin/cli.js +326 -17
  12. package/dist/plugin/cli.js.map +1 -1
  13. package/dist/plugin/index.d.ts +1 -1
  14. package/dist/plugin/index.d.ts.map +1 -1
  15. package/dist/plugin/index.js +2 -763
  16. package/dist/plugin-W_rk3Pit.js +731 -0
  17. package/dist/plugin-W_rk3Pit.js.map +1 -0
  18. package/package.json +21 -9
  19. package/src/components/FillInTheBlank.svelte +2 -2
  20. package/src/components/Matching.svelte +2 -2
  21. package/src/components/MultipleChoice.svelte +2 -2
  22. package/src/components/RevealModal.svelte +48 -103
  23. package/src/components/Sorting.svelte +2 -2
  24. package/src/components/util.ts +9 -0
  25. package/src/plugin/a11y/audit.ts +40 -8
  26. package/src/plugin/a11y-cli.ts +39 -22
  27. package/src/plugin/ast.ts +276 -0
  28. package/src/plugin/build-commands.ts +31 -0
  29. package/src/plugin/cli.ts +96 -21
  30. package/src/plugin/course-root.ts +98 -0
  31. package/src/plugin/duplicate-cli.ts +74 -0
  32. package/src/plugin/index.ts +87 -122
  33. package/src/plugin/inline-config.ts +54 -0
  34. package/src/plugin/manifest.ts +103 -136
  35. package/src/plugin/new-cli.ts +51 -0
  36. package/src/plugin/package-root.ts +24 -0
  37. package/src/plugin/project-name.ts +29 -0
  38. package/src/plugin/quiz.ts +8 -9
  39. package/src/plugin/template-copy.ts +43 -0
  40. package/src/plugin/validate-cli.ts +30 -0
  41. package/src/plugin/validation.ts +152 -244
  42. package/src/runtime/App.svelte +11 -97
  43. package/src/runtime/Sidebar.svelte +3 -1
  44. package/src/runtime/adapters/cmi5.ts +6 -10
  45. package/src/runtime/adapters/format.ts +6 -0
  46. package/src/runtime/adapters/retry.ts +1 -1
  47. package/src/runtime/adapters/scorm2004.ts +2 -4
  48. package/src/runtime/branding.ts +90 -0
  49. package/src/runtime/defaults.ts +3 -0
  50. package/src/runtime/hooks.svelte.ts +16 -53
  51. package/src/runtime/interaction-format.ts +3 -8
  52. package/src/runtime/progress.svelte.ts +47 -83
  53. package/src/runtime/xapi/derive-actor.ts +41 -48
  54. package/src/runtime/xapi/publisher.ts +14 -14
  55. package/src/runtime/xapi/setup.ts +39 -46
  56. package/templates/course/course.config.js +11 -0
  57. package/templates/course/layout.svelte +116 -0
  58. package/templates/course/pages/01-getting-started/01-welcome/_meta.js +1 -0
  59. package/templates/course/pages/01-getting-started/01-welcome/welcome.svelte +19 -0
  60. package/templates/course/pages/01-getting-started/_meta.js +1 -0
  61. package/templates/course/styles/custom.css +5 -0
  62. package/dist/audit-BBJpQGqb.js +0 -204
  63. package/dist/audit-BBJpQGqb.js.map +0 -1
  64. package/dist/plugin/a11y-cli.d.ts +0 -1
  65. package/dist/plugin/a11y-cli.js +0 -36
  66. package/dist/plugin/a11y-cli.js.map +0 -1
  67. package/dist/plugin/index.js.map +0 -1
  68. package/dist/validation-B-xTvM9B.js.map +0 -1
@@ -1,5 +1,4 @@
1
- #!/usr/bin/env node
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
- const args = process.argv.slice(2);
12
- let threshold: ImpactLevel | undefined;
13
- let rebuild = false;
10
+ export type ParsedA11yArgs =
11
+ | { ok: true; args: AuditOptions }
12
+ | { ok: false; error: string };
14
13
 
15
- for (let i = 0; i < args.length; i++) {
16
- const arg = args[i];
17
- if (arg === '--threshold') {
18
- const value = args[++i] as ImpactLevel;
19
- if (!VALID_THRESHOLDS.includes(value)) {
20
- console.error(
21
- `[tessera a11y] --threshold must be one of: ${VALID_THRESHOLDS.join(', ')}`,
22
- );
23
- process.exit(1);
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
- const code = await runAudit(process.cwd(), { threshold, rebuild });
35
- process.exit(code);
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 { validateProject, reportValidationIssues } from './validation.js';
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 projectRoot = process.cwd();
5
- const { errors, warnings } = validateProject(projectRoot);
8
+ const USAGE = `Usage: tessera <command> [course] [options]
6
9
 
7
- reportValidationIssues({ errors, warnings });
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
- if (errors.length > 0) {
10
- const summary =
11
- `Validation failed with ${errors.length} error(s)` +
12
- (warnings.length > 0 ? ` and ${warnings.length} warning(s)` : '') +
13
- '.';
14
- console.error(`\n\x1b[31m${summary}\x1b[0m`);
15
- process.exit(1);
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
- if (warnings.length > 0) {
19
- console.log(
20
- `\n\x1b[33mValidation passed with ${warnings.length} warning(s).\x1b[0m`,
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
+ }