tessera-learn 0.1.0 → 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 +54 -4
- package/README.md +4 -4
- package/dist/{audit-CzKAXy3Y.js → audit-BA5o0ick.js} +19 -6
- package/dist/audit-BA5o0ick.js.map +1 -0
- package/dist/{build-commands-D101M_qb.js → build-commands-C0OnV-Vg.js} +6 -6
- package/dist/build-commands-C0OnV-Vg.js.map +1 -0
- package/dist/{inline-config-DYHT51G8.js → inline-config-CroQ-_2Y.js} +8 -6
- package/dist/inline-config-CroQ-_2Y.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-W_rk3Pit.js} +4 -17
- package/dist/plugin-W_rk3Pit.js.map +1 -0
- package/package.json +10 -1
- package/src/plugin/a11y/audit.ts +9 -4
- 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/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
|
@@ -125,6 +125,7 @@ async function loadDeps(): Promise<
|
|
|
125
125
|
*/
|
|
126
126
|
export async function runAudit(
|
|
127
127
|
projectRoot: string,
|
|
128
|
+
workspaceRoot: string,
|
|
128
129
|
options: AuditOptions = {},
|
|
129
130
|
): Promise<number> {
|
|
130
131
|
const threshold: ImpactLevel = options.threshold ?? 'serious';
|
|
@@ -153,10 +154,14 @@ export async function runAudit(
|
|
|
153
154
|
const { resolveTesseraConfig } = await import('../inline-config.js');
|
|
154
155
|
// Carries tesseraPlugin() + the Svelte compiler; without it the plugin-less
|
|
155
156
|
// build would silently produce a broken bundle (there is no vite.config.js).
|
|
156
|
-
const auditBaseConfig = await resolveTesseraConfig(
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
157
|
+
const auditBaseConfig = await resolveTesseraConfig(
|
|
158
|
+
projectRoot,
|
|
159
|
+
workspaceRoot,
|
|
160
|
+
{
|
|
161
|
+
command: 'build',
|
|
162
|
+
mode: 'production',
|
|
163
|
+
},
|
|
164
|
+
);
|
|
160
165
|
|
|
161
166
|
// A throwaway web build, kept out of dist/ so a real LMS export is untouched.
|
|
162
167
|
const auditDist = resolve(projectRoot, 'node_modules', '.tessera-a11y');
|
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
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
title: '__PROJECT_TITLE__',
|
|
3
|
+
language: 'en',
|
|
4
|
+
navigation: { mode: 'free' },
|
|
5
|
+
completion: { mode: 'percentage', percentageThreshold: 100 },
|
|
6
|
+
scoring: { passingScore: 70 },
|
|
7
|
+
branding: {
|
|
8
|
+
primaryColor: '#0066cc',
|
|
9
|
+
},
|
|
10
|
+
export: { standard: 'web' },
|
|
11
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import '$shared/tokens.css';
|
|
3
|
+
import { useNavigation, useProgress } from 'tessera-learn';
|
|
4
|
+
|
|
5
|
+
let { page } = $props();
|
|
6
|
+
const nav = useNavigation();
|
|
7
|
+
const progress = useProgress();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<div class="course">
|
|
11
|
+
<header>
|
|
12
|
+
<h1>__PROJECT_TITLE__</h1>
|
|
13
|
+
<span class="progress"
|
|
14
|
+
>{progress.visitedPages.size} / {nav.pages.length}</span
|
|
15
|
+
>
|
|
16
|
+
</header>
|
|
17
|
+
|
|
18
|
+
<main>
|
|
19
|
+
{@render page()}
|
|
20
|
+
</main>
|
|
21
|
+
|
|
22
|
+
<footer>
|
|
23
|
+
<button
|
|
24
|
+
class="nav-btn"
|
|
25
|
+
disabled={!nav.canGoPrev}
|
|
26
|
+
onclick={() => nav.prev()}
|
|
27
|
+
>
|
|
28
|
+
← Previous
|
|
29
|
+
</button>
|
|
30
|
+
<button
|
|
31
|
+
class="nav-btn nav-btn--primary"
|
|
32
|
+
disabled={!nav.canGoNext}
|
|
33
|
+
onclick={() => nav.next()}
|
|
34
|
+
>
|
|
35
|
+
Next →
|
|
36
|
+
</button>
|
|
37
|
+
</footer>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<style>
|
|
41
|
+
.course {
|
|
42
|
+
display: flex;
|
|
43
|
+
flex-direction: column;
|
|
44
|
+
min-height: 100vh;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
header {
|
|
48
|
+
display: flex;
|
|
49
|
+
align-items: center;
|
|
50
|
+
justify-content: space-between;
|
|
51
|
+
gap: var(--tessera-spacing-md);
|
|
52
|
+
padding: var(--tessera-spacing-md) var(--tessera-spacing-xl);
|
|
53
|
+
border-bottom: 1px solid var(--tessera-border);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
header h1 {
|
|
57
|
+
margin: 0;
|
|
58
|
+
font-size: 1.125rem;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.progress {
|
|
62
|
+
flex: none;
|
|
63
|
+
padding: 0.15rem 0.6rem;
|
|
64
|
+
border-radius: 999px;
|
|
65
|
+
background: var(--tessera-bg-secondary);
|
|
66
|
+
color: var(--tessera-text-light);
|
|
67
|
+
font-size: 0.875rem;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
main {
|
|
71
|
+
flex: 1;
|
|
72
|
+
width: 100%;
|
|
73
|
+
max-width: var(--tessera-content-max-width);
|
|
74
|
+
margin: 0 auto;
|
|
75
|
+
padding: var(--tessera-spacing-xl);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
footer {
|
|
79
|
+
display: flex;
|
|
80
|
+
justify-content: space-between;
|
|
81
|
+
gap: var(--tessera-spacing-md);
|
|
82
|
+
padding: var(--tessera-spacing-md) var(--tessera-spacing-xl);
|
|
83
|
+
border-top: 1px solid var(--tessera-border);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.nav-btn {
|
|
87
|
+
padding: 0.5rem 1rem;
|
|
88
|
+
border: 1px solid var(--tessera-border);
|
|
89
|
+
border-radius: 6px;
|
|
90
|
+
background: var(--tessera-bg);
|
|
91
|
+
color: var(--tessera-text);
|
|
92
|
+
cursor: pointer;
|
|
93
|
+
transition:
|
|
94
|
+
background var(--tessera-transition-fast),
|
|
95
|
+
border-color var(--tessera-transition-fast);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.nav-btn:hover:not(:disabled) {
|
|
99
|
+
background: var(--tessera-bg-secondary);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.nav-btn--primary {
|
|
103
|
+
border-color: transparent;
|
|
104
|
+
background: var(--tessera-primary);
|
|
105
|
+
color: #fff;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.nav-btn--primary:hover:not(:disabled) {
|
|
109
|
+
background: var(--tessera-primary-dark);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.nav-btn:disabled {
|
|
113
|
+
opacity: 0.5;
|
|
114
|
+
cursor: not-allowed;
|
|
115
|
+
}
|
|
116
|
+
</style>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { title: 'Welcome', pages: ['welcome'] };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script module>
|
|
2
|
+
export const pageConfig = { title: 'Welcome' };
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script>
|
|
6
|
+
import Button from '$shared/Button.svelte';
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<h1>Welcome to __PROJECT_TITLE__</h1>
|
|
10
|
+
|
|
11
|
+
<p>This is a basic demo page of your Tessera course.</p>
|
|
12
|
+
|
|
13
|
+
<p>
|
|
14
|
+
Point your agent to <code>AGENTS.md</code> at the workspace root for the
|
|
15
|
+
authoring guide. The button below is imported from the workspace design system
|
|
16
|
+
via <code>$shared</code>.
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
<Button>A shared button</Button>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { title: 'Getting Started' };
|