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.
- package/AGENTS.md +63 -13
- package/README.md +4 -4
- package/dist/{audit-CzKAXy3Y.js → audit-BNrvFHq_.js} +66 -26
- package/dist/audit-BNrvFHq_.js.map +1 -0
- package/dist/{build-commands-D101M_qb.js → build-commands-BWnATKat.js} +6 -6
- package/dist/build-commands-BWnATKat.js.map +1 -0
- package/dist/{inline-config-DYHT51G8.js → inline-config-Dudu5r8w.js} +8 -6
- package/dist/inline-config-Dudu5r8w.js.map +1 -0
- package/dist/plugin/cli.d.ts +6 -2
- package/dist/plugin/cli.d.ts.map +1 -1
- package/dist/plugin/cli.js +242 -26
- 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 -2
- package/dist/{plugin-y35ym9A3.js → plugin-diNZaDJK.js} +4 -17
- package/dist/plugin-diNZaDJK.js.map +1 -0
- package/package.json +10 -1
- package/src/plugin/a11y/audit.ts +83 -22
- package/src/plugin/a11y-cli.ts +6 -2
- package/src/plugin/build-commands.ts +10 -4
- package/src/plugin/cli.ts +63 -20
- package/src/plugin/course-root.ts +98 -0
- package/src/plugin/duplicate-cli.ts +74 -0
- package/src/plugin/inline-config.ts +13 -2
- package/src/plugin/new-cli.ts +51 -0
- package/src/plugin/project-name.ts +29 -0
- package/src/plugin/template-copy.ts +43 -0
- package/src/plugin/validate-cli.ts +1 -1
- package/src/runtime/App.svelte +20 -1
- package/src/virtual.d.ts +6 -0
- 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-CzKAXy3Y.js.map +0 -1
- package/dist/build-commands-D101M_qb.js.map +0 -1
- package/dist/inline-config-DYHT51G8.js.map +0 -1
- package/dist/plugin-y35ym9A3.js.map +0 -1
package/src/plugin/a11y/audit.ts
CHANGED
|
@@ -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(
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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 <
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
.
|
|
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(
|
|
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 — ${
|
|
384
|
+
`\x1b[31m[tessera a11y] Failed\x1b[0m — ${reasons.join('; ')}.`,
|
|
324
385
|
);
|
|
325
386
|
}
|
|
326
387
|
}
|
package/src/plugin/a11y-cli.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|