tessera-learn 0.1.0 → 0.2.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.
Files changed (41) hide show
  1. package/AGENTS.md +63 -13
  2. package/README.md +4 -4
  3. package/dist/{audit-CzKAXy3Y.js → audit-BNrvFHq_.js} +66 -26
  4. package/dist/audit-BNrvFHq_.js.map +1 -0
  5. package/dist/{build-commands-D101M_qb.js → build-commands-BWnATKat.js} +6 -6
  6. package/dist/build-commands-BWnATKat.js.map +1 -0
  7. package/dist/{inline-config-DYHT51G8.js → inline-config-Dudu5r8w.js} +8 -6
  8. package/dist/inline-config-Dudu5r8w.js.map +1 -0
  9. package/dist/plugin/cli.d.ts +6 -2
  10. package/dist/plugin/cli.d.ts.map +1 -1
  11. package/dist/plugin/cli.js +242 -26
  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 -2
  16. package/dist/{plugin-y35ym9A3.js → plugin-diNZaDJK.js} +4 -17
  17. package/dist/plugin-diNZaDJK.js.map +1 -0
  18. package/package.json +10 -1
  19. package/src/plugin/a11y/audit.ts +83 -22
  20. package/src/plugin/a11y-cli.ts +6 -2
  21. package/src/plugin/build-commands.ts +10 -4
  22. package/src/plugin/cli.ts +63 -20
  23. package/src/plugin/course-root.ts +98 -0
  24. package/src/plugin/duplicate-cli.ts +74 -0
  25. package/src/plugin/inline-config.ts +13 -2
  26. package/src/plugin/new-cli.ts +51 -0
  27. package/src/plugin/project-name.ts +29 -0
  28. package/src/plugin/template-copy.ts +43 -0
  29. package/src/plugin/validate-cli.ts +1 -1
  30. package/src/runtime/App.svelte +20 -1
  31. package/src/virtual.d.ts +6 -0
  32. package/templates/course/course.config.js +11 -0
  33. package/templates/course/layout.svelte +116 -0
  34. package/templates/course/pages/01-getting-started/01-welcome/_meta.js +1 -0
  35. package/templates/course/pages/01-getting-started/01-welcome/welcome.svelte +19 -0
  36. package/templates/course/pages/01-getting-started/_meta.js +1 -0
  37. package/templates/course/styles/custom.css +5 -0
  38. package/dist/audit-CzKAXy3Y.js.map +0 -1
  39. package/dist/build-commands-D101M_qb.js.map +0 -1
  40. package/dist/inline-config-DYHT51G8.js.map +0 -1
  41. package/dist/plugin-y35ym9A3.js.map +0 -1
@@ -35,12 +35,16 @@ interface PageAuditResult {
35
35
  index: number;
36
36
  title: string;
37
37
  violations: AxeViolation[];
38
+ loadFailed?: boolean;
38
39
  }
39
40
 
40
41
  interface AuditReport {
41
42
  standard: A11ySettings['standard'];
42
43
  threshold: ImpactLevel;
43
44
  pages: PageAuditResult[];
45
+ pagesAudited: number;
46
+ totalPages: number;
47
+ pagesFailedToLoad: number;
44
48
  totalViolations: number;
45
49
  failingViolations: number;
46
50
  passed: boolean;
@@ -125,6 +129,7 @@ async function loadDeps(): Promise<
125
129
  */
126
130
  export async function runAudit(
127
131
  projectRoot: string,
132
+ workspaceRoot: string,
128
133
  options: AuditOptions = {},
129
134
  ): Promise<number> {
130
135
  const threshold: ImpactLevel = options.threshold ?? 'serious';
@@ -153,10 +158,14 @@ export async function runAudit(
153
158
  const { resolveTesseraConfig } = await import('../inline-config.js');
154
159
  // Carries tesseraPlugin() + the Svelte compiler; without it the plugin-less
155
160
  // build would silently produce a broken bundle (there is no vite.config.js).
156
- const auditBaseConfig = await resolveTesseraConfig(projectRoot, {
157
- command: 'build',
158
- mode: 'production',
159
- });
161
+ const auditBaseConfig = await resolveTesseraConfig(
162
+ projectRoot,
163
+ workspaceRoot,
164
+ {
165
+ command: 'build',
166
+ mode: 'production',
167
+ },
168
+ );
160
169
 
161
170
  // A throwaway web build, kept out of dist/ so a real LMS export is untouched.
162
171
  const auditDist = resolve(projectRoot, 'node_modules', '.tessera-a11y');
@@ -231,29 +240,51 @@ export async function runAudit(
231
240
  }));
232
241
  };
233
242
 
234
- const navCount = await page.locator('button.tessera-nav-page').count();
235
- if (navCount === 0) {
236
- // No sidebar (custom chrome) — audit whatever is rendered at the entry.
237
- pages.push({
238
- index: 0,
239
- title: manifest.pages[0]?.title ?? '(entry)',
240
- violations: await scan(),
241
- });
243
+ const recordPage = async (
244
+ index: number,
245
+ title: string,
246
+ ): Promise<PageAuditResult> => {
247
+ const loadFailed = await page.evaluate(
248
+ () =>
249
+ document.getElementById('tessera-app')?.dataset.tesseraPageError ===
250
+ 'true',
251
+ );
252
+ if (loadFailed) return { index, title, violations: [], loadFailed };
253
+ return { index, title, violations: await scan() };
254
+ };
255
+
256
+ const totalPages = manifest.pages.length;
257
+ const hasAuditHook = await page.evaluate(
258
+ () => typeof window.__tesseraAudit?.goToIndex === 'function',
259
+ );
260
+
261
+ if (!hasAuditHook) {
262
+ // No navigation hook — audit the entry only, but flag the reduced scope
263
+ // rather than passing it off as full coverage.
264
+ if (totalPages > 1) {
265
+ console.warn(
266
+ `\x1b[33m[tessera a11y]\x1b[0m Could not enumerate pages; auditing the entry page only ` +
267
+ `(1 of ${totalPages}). The report records the reduced scope.`,
268
+ );
269
+ }
270
+ pages.push(await recordPage(0, manifest.pages[0]?.title ?? '(entry)'));
242
271
  } else {
243
- for (let i = 0; i < navCount; i++) {
244
- const btn = page.locator('button.tessera-nav-page').nth(i);
245
- const title = (await btn.textContent())?.trim() || `Page ${i + 1}`;
246
- await btn.click();
272
+ for (let i = 0; i < totalPages; i++) {
273
+ await page.evaluate(
274
+ (idx: number) => window.__tesseraAudit!.goToIndex(idx),
275
+ i,
276
+ );
247
277
  await page.waitForFunction(
248
278
  (idx: number) =>
249
- document
250
- .querySelectorAll('button.tessera-nav-page')
251
- [idx]?.getAttribute('aria-current') === 'page',
279
+ document.getElementById('tessera-app')?.dataset
280
+ .tesseraPageIndex === String(idx),
252
281
  i,
253
282
  { timeout: 20_000 },
254
283
  );
255
284
  await page.waitForLoadState('networkidle');
256
- pages.push({ index: i, title, violations: await scan() });
285
+ pages.push(
286
+ await recordPage(i, manifest.pages[i]?.title ?? `Page ${i + 1}`),
287
+ );
257
288
  }
258
289
  }
259
290
  } finally {
@@ -263,7 +294,9 @@ export async function runAudit(
263
294
  const thresholdRank = IMPACT_RANK[threshold];
264
295
  let totalViolations = 0;
265
296
  let failingViolations = 0;
297
+ let pagesFailedToLoad = 0;
266
298
  for (const p of pages) {
299
+ if (p.loadFailed) pagesFailedToLoad++;
267
300
  for (const v of p.violations) {
268
301
  totalViolations++;
269
302
  if (isFailing(v, thresholdRank)) failingViolations++;
@@ -274,9 +307,12 @@ export async function runAudit(
274
307
  standard: settings.standard,
275
308
  threshold,
276
309
  pages,
310
+ pagesAudited: pages.length,
311
+ totalPages: manifest.pages.length,
312
+ pagesFailedToLoad,
277
313
  totalViolations,
278
314
  failingViolations,
279
- passed: failingViolations === 0,
315
+ passed: failingViolations === 0 && pagesFailedToLoad === 0,
280
316
  };
281
317
  const reportPath = resolve(projectRoot, 'a11y-report.json');
282
318
  writeFileSync(reportPath, JSON.stringify(report, null, 2), 'utf-8');
@@ -300,6 +336,10 @@ export async function runAudit(
300
336
  function printSummary(report: AuditReport, reportPath: string): void {
301
337
  const thresholdRank = IMPACT_RANK[report.threshold];
302
338
  for (const p of report.pages) {
339
+ if (p.loadFailed) {
340
+ console.log(`\x1b[31m ✗\x1b[0m ${p.title} — failed to load`);
341
+ continue;
342
+ }
303
343
  if (p.violations.length === 0) {
304
344
  console.log(`\x1b[32m ✓\x1b[0m ${p.title}`);
305
345
  continue;
@@ -314,13 +354,34 @@ function printSummary(report: AuditReport, reportPath: string): void {
314
354
  }
315
355
  }
316
356
  console.log(`\n[tessera a11y] Report written to ${reportPath}`);
357
+ if (report.pagesAudited < report.totalPages) {
358
+ console.log(
359
+ `\x1b[33m[tessera a11y] Covered ${report.pagesAudited} of ${report.totalPages} page(s)\x1b[0m — reduced scope, the rest were not audited.`,
360
+ );
361
+ } else if (report.pagesFailedToLoad > 0) {
362
+ const scanned = report.pagesAudited - report.pagesFailedToLoad;
363
+ console.log(
364
+ `[tessera a11y] Reached all ${report.totalPages} page(s); scanned ${scanned}, ${report.pagesFailedToLoad} failed to load.`,
365
+ );
366
+ } else {
367
+ console.log(`[tessera a11y] Covered all ${report.totalPages} page(s).`);
368
+ }
317
369
  if (report.passed) {
318
370
  console.log(
319
371
  `\x1b[32m[tessera a11y] Passed\x1b[0m — ${report.totalViolations} total finding(s), none at/above "${report.threshold}".`,
320
372
  );
321
373
  } else {
374
+ const reasons: string[] = [];
375
+ if (report.failingViolations > 0) {
376
+ reasons.push(
377
+ `${report.failingViolations} finding(s) at/above "${report.threshold}" (of ${report.totalViolations} total)`,
378
+ );
379
+ }
380
+ if (report.pagesFailedToLoad > 0) {
381
+ reasons.push(`${report.pagesFailedToLoad} page(s) failed to load`);
382
+ }
322
383
  console.log(
323
- `\x1b[31m[tessera a11y] Failed\x1b[0m — ${report.failingViolations} finding(s) at/above "${report.threshold}" (of ${report.totalViolations} total).`,
384
+ `\x1b[31m[tessera a11y] Failed\x1b[0m — ${reasons.join('; ')}.`,
324
385
  );
325
386
  }
326
387
  }
@@ -38,11 +38,15 @@ export function parseA11yArgs(argv: string[]): ParsedA11yArgs {
38
38
  return { ok: true, args };
39
39
  }
40
40
 
41
- export async function runA11y(argv: string[]): Promise<number> {
41
+ export async function runA11y(
42
+ projectRoot: string,
43
+ workspaceRoot: string,
44
+ argv: string[],
45
+ ): Promise<number> {
42
46
  const parsed = parseA11yArgs(argv);
43
47
  if (!parsed.ok) {
44
48
  console.error(`[tessera a11y] ${parsed.error}`);
45
49
  return 1;
46
50
  }
47
- return runAudit(process.cwd(), parsed.args);
51
+ return runAudit(projectRoot, workspaceRoot, parsed.args);
48
52
  }
@@ -1,8 +1,11 @@
1
1
  import { resolveTesseraConfig } from './inline-config.js';
2
2
 
3
- export async function runDev(projectRoot: string): Promise<number> {
3
+ export async function runDev(
4
+ projectRoot: string,
5
+ workspaceRoot: string,
6
+ ): Promise<number> {
4
7
  const vite = await import('vite');
5
- const config = await resolveTesseraConfig(projectRoot, {
8
+ const config = await resolveTesseraConfig(projectRoot, workspaceRoot, {
6
9
  command: 'serve',
7
10
  mode: 'development',
8
11
  });
@@ -14,9 +17,12 @@ export async function runDev(projectRoot: string): Promise<number> {
14
17
  return new Promise<number>(() => {});
15
18
  }
16
19
 
17
- export async function runBuild(projectRoot: string): Promise<number> {
20
+ export async function runBuild(
21
+ projectRoot: string,
22
+ workspaceRoot: string,
23
+ ): Promise<number> {
18
24
  const vite = await import('vite');
19
- const config = await resolveTesseraConfig(projectRoot, {
25
+ const config = await resolveTesseraConfig(projectRoot, workspaceRoot, {
20
26
  command: 'build',
21
27
  mode: 'production',
22
28
  });
package/src/plugin/cli.ts CHANGED
@@ -1,44 +1,87 @@
1
1
  #!/usr/bin/env node
2
2
  import { runValidate } from './validate-cli.js';
3
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';
4
7
 
5
- const USAGE = `Usage: tessera <command> [options]
8
+ const USAGE = `Usage: tessera <command> [course] [options]
6
9
 
7
10
  Commands:
8
- dev Start the Vite dev server
9
- export Build and package the course for its LMS standard
10
- validate Fast static structure checks
11
- a11y [options] Runtime accessibility audit (builds + drives Playwright)
12
- check [options] Run validate, then a11y
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
18
+
19
+ Run a command from inside a course folder, or name the course explicitly.
13
20
 
14
21
  a11y/check options:
15
22
  --threshold <minor|moderate|serious|critical> Failing impact (default: serious)
16
23
  --build Force a fresh build first`;
17
24
 
18
- export async function main(argv: string[]): Promise<number> {
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> {
19
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
+
20
47
  switch (sub) {
21
- case 'dev': {
22
- const { runDev } = await import('./build-commands.js');
23
- return runDev(process.cwd());
24
- }
25
- case 'export': {
26
- const { runBuild } = await import('./build-commands.js');
27
- return runBuild(process.cwd());
28
- }
48
+ case 'dev':
49
+ case 'export':
29
50
  case 'validate':
30
- return runValidate(process.cwd());
31
51
  case 'a11y':
32
52
  case 'check': {
33
53
  if (rest.includes('--help') || rest.includes('-h')) {
34
54
  console.log(USAGE);
35
55
  return 0;
36
56
  }
37
- if (sub === 'check') {
38
- const validateCode = runValidate(process.cwd());
39
- if (validateCode !== 0) return validateCode;
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;
40
62
  }
41
- return runA11y(rest);
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;
42
85
  }
43
86
  case '--help':
44
87
  case '-h':
@@ -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
+ }
@@ -7,11 +7,21 @@ import { tesseraPlugin } from './index.js';
7
7
  // Base Vite config for every Tessera command (dev, export, a11y build).
8
8
  // configFile:false disables Vite's own discovery — there is no vite.config.js —
9
9
  // and tesseraPlugin() supplies the Svelte compiler, so this is the full plugin set.
10
- export function buildInlineConfig(projectRoot: string): InlineConfig {
10
+ //
11
+ // $shared points at the workspace-level design system, which lives outside the
12
+ // per-course Vite root, so it is wired here (where workspaceRoot is known) rather
13
+ // than next to $assets in the plugin. server.fs.allow must list workspaceRoot or
14
+ // the dev server's fs.strict gate refuses to serve $shared files.
15
+ export function buildInlineConfig(
16
+ projectRoot: string,
17
+ workspaceRoot: string,
18
+ ): InlineConfig {
11
19
  return {
12
20
  root: projectRoot,
13
21
  configFile: false,
14
22
  plugins: [tesseraPlugin()],
23
+ resolve: { alias: { $shared: resolve(workspaceRoot, 'shared') } },
24
+ server: { fs: { allow: [workspaceRoot] } },
15
25
  };
16
26
  }
17
27
 
@@ -34,10 +44,11 @@ export async function loadUserConfig(
34
44
 
35
45
  export async function resolveTesseraConfig(
36
46
  projectRoot: string,
47
+ workspaceRoot: string,
37
48
  env: ConfigEnv,
38
49
  ): Promise<InlineConfig> {
39
50
  const vite = await import('vite');
40
- const base = buildInlineConfig(projectRoot);
51
+ const base = buildInlineConfig(projectRoot, workspaceRoot);
41
52
  const user = await loadUserConfig(projectRoot, env);
42
53
  return user ? vite.mergeConfig(base, user) : base;
43
54
  }
@@ -0,0 +1,51 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join, relative, resolve } from 'node:path';
3
+ import { findWorkspaceRoot } from './course-root.js';
4
+ import { validateProjectName, toTitleCase } from './project-name.js';
5
+ import { copyTemplate } from './template-copy.js';
6
+ import { resolvePackageRoot } from './package-root.js';
7
+
8
+ // `tessera new <name>` — stamp a new course into courses/<name> inside the
9
+ // current workspace. No install step: the workspace already owns the deps.
10
+ export function runNew(name: string | undefined, cwd: string): number {
11
+ if (name === '--help' || name === '-h') {
12
+ console.log(
13
+ 'Usage: tessera new <name>\n\n' +
14
+ 'Scaffold a new course into courses/<name> inside the current workspace.',
15
+ );
16
+ return 0;
17
+ }
18
+ if (!name) {
19
+ console.error('Usage: tessera new <name>');
20
+ return 1;
21
+ }
22
+
23
+ const nameError = validateProjectName(name, 'Course name');
24
+ if (nameError) {
25
+ console.error(`[tessera new] ${nameError}`);
26
+ return 1;
27
+ }
28
+
29
+ const workspaceRoot = findWorkspaceRoot(resolve(cwd));
30
+ if (!workspaceRoot) {
31
+ console.error(
32
+ '[tessera new] Not inside a Tessera workspace — run this from a workspace (a directory with a `courses/` folder).',
33
+ );
34
+ return 1;
35
+ }
36
+
37
+ const courseDir = join(workspaceRoot, 'courses', name);
38
+ if (existsSync(courseDir)) {
39
+ console.error(`[tessera new] Course "${name}" already exists.`);
40
+ return 1;
41
+ }
42
+
43
+ const templateDir = join(resolvePackageRoot(), 'templates', 'course');
44
+ copyTemplate(templateDir, courseDir, {
45
+ PROJECT_TITLE: toTitleCase(name),
46
+ });
47
+
48
+ const rel = relative(workspaceRoot, courseDir);
49
+ console.log(`\nCreated ${rel}.\n\nNext steps:\n pnpm tessera dev ${name}\n`);
50
+ return 0;
51
+ }
@@ -0,0 +1,29 @@
1
+ // Pure, dependency-free helpers shared by the `tessera new` subcommand and the
2
+ // `create-tessera` scaffolder. Kept import-free so create-tessera can bundle it
3
+ // at build time without dragging in the rest of the plugin (vite, svelte, …).
4
+
5
+ // npm package name rules: 1-214 chars, lowercase, must start with [a-z0-9],
6
+ // allowed chars [a-z0-9._-], no leading dot or underscore.
7
+ export function validateProjectName(
8
+ name: string,
9
+ label = 'Project name',
10
+ ): string | null {
11
+ if (!name) return `${label} is required`;
12
+ if (name.length > 214) return `${label} must be 214 characters or fewer`;
13
+ if (name !== name.toLowerCase()) return `${label} must be lowercase`;
14
+ if (!/^[a-z0-9]/.test(name)) {
15
+ return `${label} must start with a letter or digit`;
16
+ }
17
+ if (!/^[a-z0-9._-]+$/.test(name)) {
18
+ return `${label} may only contain lowercase letters, digits, "-", "_", and "."`;
19
+ }
20
+ return null;
21
+ }
22
+
23
+ export function toTitleCase(slug: string): string {
24
+ return slug
25
+ .split(/[-_.\s]+/)
26
+ .filter(Boolean)
27
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
28
+ .join(' ');
29
+ }
@@ -0,0 +1,43 @@
1
+ import {
2
+ copyFileSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ writeFileSync,
7
+ } from 'node:fs';
8
+ import { join } from 'node:path';
9
+
10
+ // npm's tarball packing strips/renames leading-dot files, so templates store
11
+ // them prefixed and we restore the dot on copy. (create-vite convention.)
12
+ const RENAME: Record<string, string> = {
13
+ _gitignore: '.gitignore',
14
+ _gitkeep: '.gitkeep',
15
+ };
16
+
17
+ // Text files get token substitution; everything else is copied byte-for-byte.
18
+ const TEXT = /\.(svelte|js|ts|json|css|md|html)$/;
19
+
20
+ function applyTokens(s: string, tokens: Record<string, string>): string {
21
+ return s.replace(/__([A-Z_]+)__/g, (m, key) =>
22
+ key in tokens ? tokens[key] : m,
23
+ );
24
+ }
25
+
26
+ export function copyTemplate(
27
+ srcDir: string,
28
+ destDir: string,
29
+ tokens: Record<string, string>,
30
+ ): void {
31
+ mkdirSync(destDir, { recursive: true });
32
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
33
+ const src = join(srcDir, entry.name);
34
+ const dest = join(destDir, RENAME[entry.name] ?? entry.name);
35
+ if (entry.isDirectory()) {
36
+ copyTemplate(src, dest, tokens);
37
+ } else if (TEXT.test(entry.name)) {
38
+ writeFileSync(dest, applyTokens(readFileSync(src, 'utf-8'), tokens));
39
+ } else {
40
+ copyFileSync(src, dest);
41
+ }
42
+ }
43
+ }
@@ -24,7 +24,7 @@ export function runValidate(projectRoot: string): number {
24
24
  );
25
25
  }
26
26
  console.log(
27
- '\x1b[2m[tessera] Static checks only. For a full runtime accessibility audit, run: tessera a11y\x1b[0m',
27
+ '\x1b[2m[tessera] Static checks only. For a full runtime accessibility audit, run: pnpm exec tessera a11y\x1b[0m',
28
28
  );
29
29
  return 0;
30
30
  }