tessera-learn 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -12,11 +12,11 @@ You probably don't want to install this package directly. Use the scaffolder:
12
12
  pnpm create tessera@latest my-course
13
13
  ```
14
14
 
15
- That creates a workspace with Tessera wired up, a seed course, and the authoring guide (`AGENTS.md`) at the workspace root. Add more courses with `pnpm tessera new <name>`.
15
+ That creates a workspace with Tessera wired up, a seed course, and a root `AGENTS.md` that points to the authoring guide. Add more courses with `pnpm tessera new <name>`.
16
16
 
17
17
  ## What's included
18
18
 
19
- - **Hooks** (`tessera-learn`): `useQuestion`, `useQuiz`, `useNavigation`, `useProgress`, `usePersistence`, `useXAPI`.
19
+ - **Hooks** (`tessera-learn`): `useQuestion`, `useQuiz`, `useNavigation`, `useProgress`, `useCompletion`, `usePersistence`, `useXAPI`.
20
20
  - **Vite plugin** (`tessera-learn/plugin`): `tesseraPlugin()` — wires page/layout discovery, the LMS adapter, the `$shared` alias, and the export pipeline. The `tessera` CLI (`new`/`dev`/`export`/`validate`/`a11y`/`check`) runs Vite with this plugin for you, so scaffolded workspaces need no `vite.config.js`.
21
21
  - **Built-in components** (`tessera-learn`): `Callout`, `Image`, `Audio`, `Video`, `Accordion` / `AccordionItem`, `Carousel` / `CarouselSlide`, `RevealModal`, `Quiz`, `MultipleChoice`, `FillInTheBlank`, `Matching`, `Sorting`, `DefaultLayout`.
22
22
  - **LMS adapters**: SCORM 1.2, SCORM 2004 4th Edition, cmi5, static Web — selected via `course.config.js` `export.standard`.
@@ -26,7 +26,7 @@ See `AGENTS.md` for usage, signatures, and authoring conventions.
26
26
 
27
27
  ## Documentation
28
28
 
29
- The full authoring guide ships with this package at `node_modules/tessera-learn/AGENTS.md`, at the root of any scaffolded project, and on [GitHub](https://github.com/redmodd/tessera/blob/main/AGENTS.md).
29
+ The full authoring guide ships with this package at `node_modules/tessera-learn/AGENTS.md` scaffolded projects get a small root `AGENTS.md` that points to it — and is on [GitHub](https://github.com/redmodd/tessera/blob/main/packages/tessera-learn/AGENTS.md).
30
30
 
31
31
  ## License
32
32
 
@@ -1,9 +1,11 @@
1
+ import { createRequire } from "node:module";
1
2
  import { basename, dirname, extname, relative, resolve } from "node:path";
2
3
  import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
3
4
  import JSON5 from "json5";
4
5
  import { Parser } from "acorn";
5
6
  import { tsPlugin } from "@sveltejs/acorn-typescript";
6
7
  import { parse } from "svelte/compiler";
8
+ import { spawn } from "node:child_process";
7
9
  //#region src/plugin/ast.ts
8
10
  const rootCache = /* @__PURE__ */ new Map();
9
11
  /** Drop every cached root. Call at the start of a run to scope the cache. */
@@ -1385,9 +1387,132 @@ function axeTags(standard) {
1385
1387
  function axeIgnoreRules(ignore) {
1386
1388
  return ignore.filter((id) => !id.startsWith("tessera/") && !id.startsWith("a11y_"));
1387
1389
  }
1390
+ const MAX_HTML_LENGTH = 200;
1391
+ const MAX_ELEMENTS_SHOWN = 5;
1392
+ function mapNodeDetail(node) {
1393
+ const target = Array.isArray(node?.target) ? node.target.flat(Infinity).join(" ") : String(node?.target ?? "");
1394
+ const html = String(node?.html ?? "");
1395
+ return {
1396
+ target,
1397
+ html: html.length > MAX_HTML_LENGTH ? `${html.slice(0, MAX_HTML_LENGTH - 1)}…` : html,
1398
+ summary: String(node?.failureSummary ?? "").replace(/\s*\n\s*/g, " ").trim()
1399
+ };
1400
+ }
1401
+ function mapViolation(v) {
1402
+ return {
1403
+ id: v.id,
1404
+ impact: v.impact ?? null,
1405
+ help: v.help,
1406
+ helpUrl: v.helpUrl,
1407
+ nodes: v.nodes.length,
1408
+ elements: v.nodes.map(mapNodeDetail)
1409
+ };
1410
+ }
1388
1411
  function isMissingBrowserError(message) {
1389
1412
  return /Executable doesn't exist|playwright install/i.test(message);
1390
1413
  }
1414
+ function isMissingDepsError(message) {
1415
+ return /Host system is missing dependencies|missing dependencies to run browser/i.test(message);
1416
+ }
1417
+ const INSTALL_CHROMIUM = "pnpm exec playwright install chromium";
1418
+ const PLAYWRIGHT_SPECS = ["playwright", "@playwright/test"];
1419
+ function reportManualInstall(lead) {
1420
+ console.error(`\x1b[31m[tessera a11y]\x1b[0m ${lead}\n Install it once:\n ${INSTALL_CHROMIUM}`);
1421
+ }
1422
+ function resolvePlaywrightBin() {
1423
+ const require = createRequire(import.meta.url);
1424
+ for (const spec of PLAYWRIGHT_SPECS) try {
1425
+ const pkgPath = require.resolve(`${spec}/package.json`);
1426
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
1427
+ const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.playwright;
1428
+ if (!binRel) continue;
1429
+ return {
1430
+ command: process.execPath,
1431
+ args: [
1432
+ resolve(dirname(pkgPath), binRel),
1433
+ "install",
1434
+ "chromium"
1435
+ ]
1436
+ };
1437
+ } catch {
1438
+ continue;
1439
+ }
1440
+ }
1441
+ const INSTALL_TIMEOUT_MS = 10 * 6e4;
1442
+ async function installChromium(workspaceRoot, spawnFn = spawn, timeoutMs = INSTALL_TIMEOUT_MS) {
1443
+ const bin = resolvePlaywrightBin();
1444
+ if (!bin) {
1445
+ console.error(`\x1b[31m[tessera a11y]\x1b[0m Could not locate the Playwright CLI to install Chromium.`);
1446
+ return false;
1447
+ }
1448
+ return new Promise((resolvePromise) => {
1449
+ const child = spawnFn(bin.command, bin.args, {
1450
+ stdio: "inherit",
1451
+ cwd: workspaceRoot
1452
+ });
1453
+ let settled = false;
1454
+ const finish = (ok) => {
1455
+ if (settled) return;
1456
+ settled = true;
1457
+ clearTimeout(timer);
1458
+ resolvePromise(ok);
1459
+ };
1460
+ const timer = setTimeout(() => {
1461
+ console.error(`\x1b[31m[tessera a11y]\x1b[0m Chromium install timed out after ${Math.round(timeoutMs / 6e4)} min; aborting.`);
1462
+ child.kill?.("SIGKILL");
1463
+ finish(false);
1464
+ }, timeoutMs);
1465
+ timer.unref?.();
1466
+ child.on("error", (err) => {
1467
+ console.error(`\x1b[31m[tessera a11y]\x1b[0m Failed to start the Chromium install: ${err.message}`);
1468
+ finish(false);
1469
+ });
1470
+ child.on("exit", (code) => finish(code === 0));
1471
+ });
1472
+ }
1473
+ function reportLaunchFailure(message, isLinux) {
1474
+ console.error(`\x1b[31m[tessera a11y]\x1b[0m Chromium is installed but failed to launch.\n` + (isLinux ? ` Install system dependencies:\n pnpm exec playwright install --with-deps chromium\n` : ``) + ` Original error: ${message}`);
1475
+ }
1476
+ async function launchWithInstall({ launch, install, isLinux = process.platform === "linux" }) {
1477
+ try {
1478
+ return {
1479
+ ok: true,
1480
+ browser: await launch()
1481
+ };
1482
+ } catch (err) {
1483
+ const message = err instanceof Error ? err.message : String(err);
1484
+ if (isMissingDepsError(message)) {
1485
+ reportLaunchFailure(message, isLinux);
1486
+ return {
1487
+ ok: false,
1488
+ code: 1
1489
+ };
1490
+ }
1491
+ if (!isMissingBrowserError(message)) throw err;
1492
+ console.log("[tessera a11y] Chromium isn't installed for Playwright. Installing it once now…");
1493
+ if (!await install()) {
1494
+ reportManualInstall("Chromium isn't installed for Playwright.");
1495
+ return {
1496
+ ok: false,
1497
+ code: 1
1498
+ };
1499
+ }
1500
+ try {
1501
+ return {
1502
+ ok: true,
1503
+ browser: await launch()
1504
+ };
1505
+ } catch (retryErr) {
1506
+ const retryMessage = retryErr instanceof Error ? retryErr.message : String(retryErr);
1507
+ if (isMissingBrowserError(retryMessage) && !isMissingDepsError(retryMessage)) reportManualInstall("Chromium still isn't installed after the install step.");
1508
+ else reportLaunchFailure(retryMessage, isLinux);
1509
+ return {
1510
+ ok: false,
1511
+ code: 1
1512
+ };
1513
+ }
1514
+ }
1515
+ }
1391
1516
  function isFailing(v, thresholdRank) {
1392
1517
  return !v.impact || IMPACT_RANK[v.impact] >= thresholdRank;
1393
1518
  }
@@ -1396,7 +1521,7 @@ async function tryImport(specifier) {
1396
1521
  }
1397
1522
  async function loadDeps() {
1398
1523
  let chromium;
1399
- for (const spec of ["playwright", "@playwright/test"]) try {
1524
+ for (const spec of PLAYWRIGHT_SPECS) try {
1400
1525
  const mod = await tryImport(spec);
1401
1526
  if (mod.chromium) {
1402
1527
  chromium = mod.chromium;
@@ -1436,7 +1561,10 @@ async function runAudit(projectRoot, workspaceRoot, options = {}) {
1436
1561
  const threshold = options.threshold ?? "serious";
1437
1562
  const deps = await loadDeps();
1438
1563
  if (!deps.ok) {
1439
- console.error("\x1B[31m[tessera a11y]\x1B[0m Tier 2 needs Playwright + axe-core, which aren't installed.\n Install them to run the runtime audit:\n pnpm add -D playwright @axe-core/playwright\n pnpm exec playwright install chromium");
1564
+ console.error(`[tessera a11y] Tier 2 needs Playwright + axe-core, which aren't installed.
1565
+ Install them to run the runtime audit:
1566
+ pnpm add -D playwright @axe-core/playwright
1567
+ ${INSTALL_CHROMIUM}`);
1440
1568
  return 1;
1441
1569
  }
1442
1570
  const { chromium, AxeBuilder } = deps.deps;
@@ -1446,7 +1574,7 @@ async function runAudit(projectRoot, workspaceRoot, options = {}) {
1446
1574
  const disableRules = axeIgnoreRules(settings.ignore);
1447
1575
  const manifest = generateManifest(resolve(projectRoot, "pages"));
1448
1576
  const vite = await import("vite");
1449
- const { resolveTesseraConfig } = await import("./inline-config-CroQ-_2Y.js");
1577
+ const { resolveTesseraConfig } = await import("./inline-config-eHjv9XuA.js");
1450
1578
  const auditBaseConfig = await resolveTesseraConfig(projectRoot, workspaceRoot, {
1451
1579
  command: "build",
1452
1580
  mode: "production"
@@ -1482,16 +1610,12 @@ async function runAudit(projectRoot, workspaceRoot, options = {}) {
1482
1610
  console.error("[tessera a11y] Could not determine preview server URL.");
1483
1611
  return 1;
1484
1612
  }
1485
- let browser;
1486
- try {
1487
- browser = await chromium.launch();
1488
- } catch (err) {
1489
- if (isMissingBrowserError(err instanceof Error ? err.message : String(err))) {
1490
- console.error("\x1B[31m[tessera a11y]\x1B[0m Chromium isn't installed for Playwright.\n Install it once:\n pnpm exec playwright install chromium");
1491
- return 1;
1492
- }
1493
- throw err;
1494
- }
1613
+ const launched = await launchWithInstall({
1614
+ launch: () => chromium.launch(),
1615
+ install: () => installChromium(workspaceRoot)
1616
+ });
1617
+ if (!launched.ok) return launched.code;
1618
+ const browser = launched.browser;
1495
1619
  const pages = [];
1496
1620
  try {
1497
1621
  const page = await (await browser.newContext()).newPage();
@@ -1502,31 +1626,31 @@ async function runAudit(projectRoot, workspaceRoot, options = {}) {
1502
1626
  const scan = async () => {
1503
1627
  const builder = new AxeBuilder({ page }).withTags(tags);
1504
1628
  if (disableRules.length > 0) builder.disableRules(disableRules);
1505
- return (await builder.analyze()).violations.map((v) => ({
1506
- id: v.id,
1507
- impact: v.impact ?? null,
1508
- help: v.help,
1509
- helpUrl: v.helpUrl,
1510
- nodes: v.nodes.length
1511
- }));
1629
+ return (await builder.analyze()).violations.map(mapViolation);
1512
1630
  };
1513
- const navCount = await page.locator("button.tessera-nav-page").count();
1514
- if (navCount === 0) pages.push({
1515
- index: 0,
1516
- title: manifest.pages[0]?.title ?? "(entry)",
1517
- violations: await scan()
1518
- });
1519
- else for (let i = 0; i < navCount; i++) {
1520
- const btn = page.locator("button.tessera-nav-page").nth(i);
1521
- const title = (await btn.textContent())?.trim() || `Page ${i + 1}`;
1522
- await btn.click();
1523
- await page.waitForFunction((idx) => document.querySelectorAll("button.tessera-nav-page")[idx]?.getAttribute("aria-current") === "page", i, { timeout: 2e4 });
1524
- await page.waitForLoadState("networkidle");
1525
- pages.push({
1526
- index: i,
1631
+ const recordPage = async (index, title) => {
1632
+ const loadFailed = await page.evaluate(() => document.getElementById("tessera-app")?.dataset.tesseraPageError === "true");
1633
+ if (loadFailed) return {
1634
+ index,
1635
+ title,
1636
+ violations: [],
1637
+ loadFailed
1638
+ };
1639
+ return {
1640
+ index,
1527
1641
  title,
1528
1642
  violations: await scan()
1529
- });
1643
+ };
1644
+ };
1645
+ const totalPages = manifest.pages.length;
1646
+ if (!await page.evaluate(() => typeof window.__tesseraAudit?.goToIndex === "function")) {
1647
+ if (totalPages > 1) console.warn(`\x1b[33m[tessera a11y]\x1b[0m Could not enumerate pages; auditing the entry page only (1 of ${totalPages}). The report records the reduced scope.`);
1648
+ pages.push(await recordPage(0, manifest.pages[0]?.title ?? "(entry)"));
1649
+ } else for (let i = 0; i < totalPages; i++) {
1650
+ await page.evaluate((idx) => window.__tesseraAudit.goToIndex(idx), i);
1651
+ await page.waitForFunction((idx) => document.getElementById("tessera-app")?.dataset.tesseraPageIndex === String(idx), i, { timeout: 2e4 });
1652
+ await page.waitForLoadState("networkidle");
1653
+ pages.push(await recordPage(i, manifest.pages[i]?.title ?? `Page ${i + 1}`));
1530
1654
  }
1531
1655
  } finally {
1532
1656
  await browser.close();
@@ -1534,17 +1658,24 @@ async function runAudit(projectRoot, workspaceRoot, options = {}) {
1534
1658
  const thresholdRank = IMPACT_RANK[threshold];
1535
1659
  let totalViolations = 0;
1536
1660
  let failingViolations = 0;
1537
- for (const p of pages) for (const v of p.violations) {
1538
- totalViolations++;
1539
- if (isFailing(v, thresholdRank)) failingViolations++;
1661
+ let pagesFailedToLoad = 0;
1662
+ for (const p of pages) {
1663
+ if (p.loadFailed) pagesFailedToLoad++;
1664
+ for (const v of p.violations) {
1665
+ totalViolations++;
1666
+ if (isFailing(v, thresholdRank)) failingViolations++;
1667
+ }
1540
1668
  }
1541
1669
  const report = {
1542
1670
  standard: settings.standard,
1543
1671
  threshold,
1544
1672
  pages,
1673
+ pagesAudited: pages.length,
1674
+ totalPages: manifest.pages.length,
1675
+ pagesFailedToLoad,
1545
1676
  totalViolations,
1546
1677
  failingViolations,
1547
- passed: failingViolations === 0
1678
+ passed: failingViolations === 0 && pagesFailedToLoad === 0
1548
1679
  };
1549
1680
  const reportPath = resolve(projectRoot, "a11y-report.json");
1550
1681
  writeFileSync(reportPath, JSON.stringify(report, null, 2), "utf-8");
@@ -1562,19 +1693,41 @@ async function runAudit(projectRoot, workspaceRoot, options = {}) {
1562
1693
  function printSummary(report, reportPath) {
1563
1694
  const thresholdRank = IMPACT_RANK[report.threshold];
1564
1695
  for (const p of report.pages) {
1696
+ if (p.loadFailed) {
1697
+ console.log(`\x1b[31m ✗\x1b[0m ${p.title} — failed to load`);
1698
+ continue;
1699
+ }
1565
1700
  if (p.violations.length === 0) {
1566
1701
  console.log(`\x1b[32m ✓\x1b[0m ${p.title}`);
1567
1702
  continue;
1568
1703
  }
1569
1704
  const mark = p.violations.some((v) => isFailing(v, thresholdRank)) ? "\x1B[31m ✗\x1B[0m" : "\x1B[33m ⚠\x1B[0m";
1570
1705
  console.log(`${mark} ${p.title}`);
1571
- for (const v of p.violations) console.log(` [${v.impact ?? "n/a"}] ${v.id} — ${v.help} (${v.nodes} node${v.nodes === 1 ? "" : "s"})`);
1706
+ for (const v of p.violations) {
1707
+ console.log(` [${v.impact ?? "n/a"}] ${v.id} — ${v.help} (${v.nodes} node${v.nodes === 1 ? "" : "s"})`);
1708
+ for (const el of v.elements.slice(0, MAX_ELEMENTS_SHOWN)) {
1709
+ console.log(`\x1b[90m → ${el.target || "(unknown element)"}\x1b[0m`);
1710
+ if (el.summary) console.log(`\x1b[90m ${el.summary}\x1b[0m`);
1711
+ }
1712
+ const hidden = v.elements.length - MAX_ELEMENTS_SHOWN;
1713
+ if (hidden > 0) console.log(`\x1b[90m … and ${hidden} more — see a11y-report.json\x1b[0m`);
1714
+ }
1572
1715
  }
1573
1716
  console.log(`\n[tessera a11y] Report written to ${reportPath}`);
1717
+ if (report.pagesAudited < report.totalPages) console.log(`\x1b[33m[tessera a11y] Covered ${report.pagesAudited} of ${report.totalPages} page(s)\x1b[0m — reduced scope, the rest were not audited.`);
1718
+ else if (report.pagesFailedToLoad > 0) {
1719
+ const scanned = report.pagesAudited - report.pagesFailedToLoad;
1720
+ console.log(`[tessera a11y] Reached all ${report.totalPages} page(s); scanned ${scanned}, ${report.pagesFailedToLoad} failed to load.`);
1721
+ } else console.log(`[tessera a11y] Covered all ${report.totalPages} page(s).`);
1574
1722
  if (report.passed) console.log(`\x1b[32m[tessera a11y] Passed\x1b[0m — ${report.totalViolations} total finding(s), none at/above "${report.threshold}".`);
1575
- else console.log(`\x1b[31m[tessera a11y] Failed\x1b[0m — ${report.failingViolations} finding(s) at/above "${report.threshold}" (of ${report.totalViolations} total).`);
1723
+ else {
1724
+ const reasons = [];
1725
+ if (report.failingViolations > 0) reasons.push(`${report.failingViolations} finding(s) at/above "${report.threshold}" (of ${report.totalViolations} total)`);
1726
+ if (report.pagesFailedToLoad > 0) reasons.push(`${report.pagesFailedToLoad} page(s) failed to load`);
1727
+ console.log(`\x1b[31m[tessera a11y] Failed\x1b[0m — ${reasons.join("; ")}.`);
1728
+ }
1576
1729
  }
1577
1730
  //#endregion
1578
1731
  export { isPlausibleLanguageTag as a, validateProject as c, isIgnored as i, generateManifest as l, runAudit as n, normalizeA11y as o, resolvePackageRoot as r, reportValidationIssues as s, AUDIT_ENV_FLAG as t, readCourseConfig as u };
1579
1732
 
1580
- //# sourceMappingURL=audit-BA5o0ick.js.map
1733
+ //# sourceMappingURL=audit-B9VHgVjk.js.map