Skip to content

Commit c54a2e0

Browse files
redmoddclaude
andauthored
refactor: centralize export-standard resolution (#100)
## Summary The effective export standard (course.config.js `export.standard` + CLI `--standard` override) was re-derived in ~8 places via three different idioms: `resolveExportStandard` (returns a string), `applyStandardOverride` (returns a new config), and an inline mutation in validation. This unifies the build-plugin path behind one helper. - New `readResolvedConfig(projectRoot, override)` in `manifest.ts` — owns override-application plus the `web`/`unknown` defaults, returns `CourseConfigRead & { standard: string }`. - Deletes `resolveExportStandard` and `applyStandardOverride`; the 5 call sites (cspMeta, entry/config/adapter/xapi-setup/export plugins) each collapse to a single `readResolvedConfig` call. - `validation.ts`'s inline override is intentionally left as-is: it must validate the file's raw `export.standard` *before* overriding, so its explicit ordering is clearer than forcing the helper. No user-facing behavior change. ## Testing - `pnpm --filter tessera-learn test` → 974 passed - `pnpm check` → prettier + eslint + svelte-check clean - Rewrote `export-standard-override.test.ts` to cover `readResolvedConfig` (config value, web default, override-wins, field preservation, `unknown` fail-closed, override-on-unreadable). Modes: pure build/CLI resolution refactor — exercised across all standards by the existing unit suite (web/scorm12/scorm2004/cmi5/xapi); no runtime adapter behavior changed. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent db4c3a3 commit c54a2e0

4 files changed

Lines changed: 92 additions & 84 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'tessera-learn': patch
3+
'create-tessera': patch
4+
---
5+
6+
Centralize export-standard resolution behind a single `readResolvedConfig` helper (no user-facing change).

packages/tessera-learn/src/plugin/index.ts

Lines changed: 17 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import {
1414
generateManifest,
1515
readCourseConfig,
16+
readResolvedConfig,
1617
type CourseConfigRead,
1718
} from './manifest.js';
1819
import type { Manifest } from './manifest.js';
@@ -194,13 +195,10 @@ function tesseraEntryPlugin(standardOverride?: string): Plugin {
194195
// For build mode: write index.html so Rollup can find it
195196
buildStart() {
196197
if (isBuild) {
197-
const read = readCourseConfig(projectRoot);
198+
const read = readResolvedConfig(projectRoot, standardOverride);
198199
writeFileSync(
199200
resolve(projectRoot, 'index.html'),
200-
generateIndexHtml(
201-
readLanguage(read),
202-
cspMeta(read, standardOverride),
203-
),
201+
generateIndexHtml(readLanguage(read), cspMeta(read)),
204202
'utf-8',
205203
);
206204
}
@@ -269,32 +267,12 @@ function readLanguage(read: CourseConfigRead): string {
269267
return isPlausibleLanguageTag(lang) ? lang : 'en';
270268
}
271269

272-
// Override wins, else course.config.js, else 'web'. An unreadable config with no
273-
// override fails closed with 'unknown' so cspMeta withholds the CSP rather than
274-
// guess the one mode it breaks. Exported for tests.
275-
export function resolveExportStandard(
276-
read: CourseConfigRead,
277-
override?: string,
278-
): string {
279-
if (override) return override;
280-
if (!read.ok) return 'unknown';
281-
return read.config.export?.standard || 'web';
282-
}
283-
284-
/** Apply a CLI `--standard` override onto a config's export.standard. Exported for tests. */
285-
export function applyStandardOverride<
286-
T extends { export?: { standard?: string } },
287-
>(config: T, override?: string): T {
288-
if (!override) return config;
289-
return { ...config, export: { ...config.export, standard: override } };
290-
}
291-
292270
// Web export only — never on LMS packages (whose iframe JS bridges a meta CSP
293271
// could break) and never on the dev server (a meta connect-src would block
294272
// Vite's HMR websocket). `export.csp` extends the baseline per-directive, or
295273
// `false` drops the meta for deployments that set a CSP header themselves.
296-
function cspMeta(read: CourseConfigRead, override?: string): string {
297-
if (resolveExportStandard(read, override) !== 'web') return '';
274+
function cspMeta(read: CourseConfigRead & { standard: string }): string {
275+
if (read.standard !== 'web') return '';
298276
const csp = read.ok ? read.config.export?.csp : undefined;
299277
if (csp === false) return '';
300278
return `\n <meta http-equiv="Content-Security-Policy" content="${buildCsp(csp)}" />`;
@@ -424,12 +402,11 @@ function tesseraConfigPlugin(standardOverride?: string): Plugin {
424402
function ({ projectRoot }) {
425403
const configPath = resolve(projectRoot, 'course.config.js');
426404
if (existsSync(configPath)) this.addWatchFile(configPath);
427-
const read = readCourseConfig(projectRoot);
405+
// The runtime reads export.standard too, so readResolvedConfig must apply
406+
// the override here — the bundled config, not just the manifest/adapter.
407+
const read = readResolvedConfig(projectRoot, standardOverride);
428408
const userConfig: Partial<CourseConfig> = read.ok ? read.config : {};
429-
// The runtime reads export.standard too, so the override must reach the
430-
// bundled config, not just the manifest/adapter.
431-
const withOverride = applyStandardOverride(userConfig, standardOverride);
432-
return `export default ${JSON.stringify(mergeCourseConfig(withOverride))};`;
409+
return `export default ${JSON.stringify(mergeCourseConfig(userConfig))};`;
433410
},
434411
);
435412
}
@@ -556,7 +533,7 @@ function tesseraExportPlugin(standardOverride?: string): Plugin {
556533
if (!isBuild) return;
557534
if (isAuditBuild()) return;
558535

559-
const read = readCourseConfig(projectRoot);
536+
const read = readResolvedConfig(projectRoot, standardOverride);
560537
if (!read.ok) {
561538
// Validation already required a parseable course.config.js — getting
562539
// here means it vanished or broke mid-build. Surface that loudly
@@ -576,8 +553,10 @@ function tesseraExportPlugin(standardOverride?: string): Plugin {
576553
);
577554
}
578555

579-
const config = applyStandardOverride(read.config, standardOverride);
580-
await runExport(projectRoot, config as Parameters<typeof runExport>[1]);
556+
await runExport(
557+
projectRoot,
558+
read.config as Parameters<typeof runExport>[1],
559+
);
581560
},
582561
};
583562
}
@@ -735,10 +714,7 @@ function tesseraAdapterPlugin(standardOverride?: string): Plugin {
735714
return `export { createAdapter } from 'tessera-learn/runtime/adapters/index.js';`;
736715
}
737716

738-
let standard = resolveExportStandard(
739-
readCourseConfig(projectRoot),
740-
standardOverride,
741-
);
717+
let standard = readResolvedConfig(projectRoot, standardOverride).standard;
742718

743719
// The audit renders headless with no LMS in the frame chain; the SCORM/
744720
// cmi5 adapters throw when their API is absent, so render with WebAdapter.
@@ -775,8 +751,8 @@ function tesseraXAPISetupPlugin(standardOverride?: string): Plugin {
775751
return `export async function buildXAPIClient() { return null; }`;
776752
}
777753

778-
const read = readCourseConfig(projectRoot);
779-
const standard = resolveExportStandard(read, standardOverride);
754+
const read = readResolvedConfig(projectRoot, standardOverride);
755+
const standard = read.standard;
780756
const hasXapi = read.ok && read.config.xapi != null;
781757

782758
// The launch standards (cmi5, plain xAPI) own a publisher the runtime

packages/tessera-learn/src/plugin/manifest.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,27 @@ export function readCourseConfig(projectRoot: string): CourseConfigRead {
121121
}
122122
}
123123

124+
/**
125+
* Resolve a project's effective export standard once: the CLI `--standard`
126+
* override wins, else `export.standard`, else `'web'`. An unreadable config with
127+
* no override fails closed with `'unknown'` so callers withhold standard-specific
128+
* output rather than guess. The returned `config` already has the override
129+
* applied, so consumers read it back directly. Exported for tests.
130+
*/
131+
export function readResolvedConfig(
132+
projectRoot: string,
133+
standardOverride?: string,
134+
): CourseConfigRead & { standard: string } {
135+
const read = readCourseConfig(projectRoot);
136+
if (!read.ok) return { ...read, standard: standardOverride ?? 'unknown' };
137+
// The CLI validates --standard against the allowed set before it reaches here.
138+
const override = standardOverride as CourseConfig['export']['standard'];
139+
const config: Partial<CourseConfig> = override
140+
? { ...read.config, export: { ...read.config.export, standard: override } }
141+
: read.config;
142+
return { ok: true, config, standard: config.export?.standard || 'web' };
143+
}
144+
124145
/**
125146
* Read a _meta.js file and extract its default export object.
126147
* Uses the same JSON5 approach as pageConfig extraction — find the object literal
Lines changed: 48 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,64 @@
1-
import { describe, it, expect } from 'vitest';
2-
import {
3-
resolveExportStandard,
4-
applyStandardOverride,
5-
} from '../src/plugin/index.js';
6-
import type { CourseConfigRead } from '../src/plugin/manifest.js';
7-
8-
const ok = (standard?: string): CourseConfigRead => ({
9-
ok: true,
10-
config: standard === undefined ? {} : { export: { standard } },
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
3+
import { resolve } from 'node:path';
4+
import { tmpdir } from 'node:os';
5+
import { readResolvedConfig } from '../src/plugin/manifest.js';
6+
7+
let projectRoot: string;
8+
9+
beforeEach(() => {
10+
projectRoot = resolve(
11+
tmpdir(),
12+
`tessera-resolve-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
13+
);
14+
mkdirSync(projectRoot, { recursive: true });
15+
});
16+
17+
afterEach(() => {
18+
if (existsSync(projectRoot))
19+
rmSync(projectRoot, { recursive: true, force: true });
1120
});
1221

13-
describe('resolveExportStandard', () => {
22+
function writeConfig(body: string) {
23+
writeFileSync(
24+
resolve(projectRoot, 'course.config.js'),
25+
`export default ${body};`,
26+
'utf-8',
27+
);
28+
}
29+
30+
describe('readResolvedConfig', () => {
1431
it('uses the course config standard when no override is given', () => {
15-
expect(resolveExportStandard(ok('scorm12'))).toBe('scorm12');
32+
writeConfig(`{ export: { standard: "scorm12" } }`);
33+
const read = readResolvedConfig(projectRoot);
34+
expect(read.standard).toBe('scorm12');
35+
expect(read.ok && read.config.export?.standard).toBe('scorm12');
1636
});
1737

1838
it('defaults to web when the config omits export.standard', () => {
19-
expect(resolveExportStandard(ok())).toBe('web');
39+
writeConfig(`{ title: "x" }`);
40+
expect(readResolvedConfig(projectRoot).standard).toBe('web');
2041
});
2142

22-
it('lets a CLI override win over the config standard', () => {
23-
expect(resolveExportStandard(ok('web'), 'cmi5')).toBe('cmi5');
43+
it('lets a CLI override win while preserving other export fields', () => {
44+
writeConfig(`{ export: { standard: "web", csp: false } }`);
45+
const read = readResolvedConfig(projectRoot, 'cmi5');
46+
expect(read.standard).toBe('cmi5');
47+
expect(read.ok && read.config.export).toEqual({
48+
standard: 'cmi5',
49+
csp: false,
50+
});
2451
});
2552

2653
it('reports unknown for an unreadable config with no override', () => {
27-
expect(resolveExportStandard({ ok: false, reason: 'missing' })).toBe(
28-
'unknown',
29-
);
54+
const read = readResolvedConfig(projectRoot);
55+
expect(read.ok).toBe(false);
56+
expect(read.standard).toBe('unknown');
3057
});
3158

3259
it('honours the override even when the config is unreadable', () => {
33-
expect(
34-
resolveExportStandard({ ok: false, reason: 'missing' }, 'scorm2004'),
35-
).toBe('scorm2004');
36-
});
37-
});
38-
39-
describe('applyStandardOverride', () => {
40-
it('returns the config unchanged when no override is given', () => {
41-
const config = { export: { standard: 'web' } };
42-
expect(applyStandardOverride(config)).toBe(config);
43-
});
44-
45-
it('overrides export.standard while preserving other export fields', () => {
46-
expect(
47-
applyStandardOverride(
48-
{ export: { standard: 'web', csp: false } },
49-
'cmi5',
50-
),
51-
).toEqual({ export: { standard: 'cmi5', csp: false } });
52-
});
53-
54-
it('sets export.standard when the config has no export block', () => {
55-
expect(applyStandardOverride({}, 'scorm2004')).toEqual({
56-
export: { standard: 'scorm2004' },
57-
});
60+
const read = readResolvedConfig(projectRoot, 'scorm2004');
61+
expect(read.ok).toBe(false);
62+
expect(read.standard).toBe('scorm2004');
5863
});
5964
});

0 commit comments

Comments
 (0)