Skip to content

Commit 65c5eb7

Browse files
authored
Update course manifest structure for scaffolded courses (#950)
Updates CLI and standalone-ui to use the new hierarchical manifest structure that was introduced for the docs site but not propagated to scaffolded applications. Changes: - CLI now generates course-level skuilder.json for each course - CLI now generates root skuilder.json in public/ with course dependencies - standalone-ui updated to fetch root manifest and use new DataLayerProvider API The new structure has three levels: 1. Root /public/skuilder.json (lists course dependencies) 2. Course /static-courses/{id}/skuilder.json (points to manifest.json) 3. Course /static-courses/{id}/manifest.json (contains courseConfig and data) This matches the implementation in docs/.vitepress/theme/composables/useStaticDataLayer.ts and the updated StaticDataLayerProvider API from commits 4fb97b0, 0e2fb4b, 40aaf39. Fixes runtime failures in newly scaffolded static apps caused by API mismatch.
2 parents 1d7f561 + cee1e35 commit 65c5eb7

File tree

11 files changed

+284
-24
lines changed

11 files changed

+284
-24
lines changed

.github/workflows/ci-pkg-cli.yml

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
name: ci-pkg-cli
2+
permissions:
3+
contents: read
4+
5+
on:
6+
pull_request:
7+
paths:
8+
- 'packages/cli/**'
9+
- 'packages/common/**'
10+
- 'packages/db/**'
11+
- 'packages/common-ui/**'
12+
- 'packages/courseware/**'
13+
- 'packages/express/**'
14+
- 'packages/standalone-ui/**'
15+
- '.github/workflows/ci-pkg-cli.yml'
16+
- 'package.json'
17+
- 'yarn.lock'
18+
19+
jobs:
20+
cli-try-init-e2e:
21+
runs-on: ubuntu-latest
22+
23+
steps:
24+
- name: Checkout repository
25+
uses: actions/checkout@v4
26+
with:
27+
submodules: 'recursive'
28+
29+
- name: Display submodule information
30+
run: |
31+
echo "Submodule information:"
32+
git submodule status
33+
echo "CouchDB snapshot details:"
34+
cd test-couch && git log -1 --pretty=format:'%h - %s (%cr) <%an>'
35+
36+
- name: Setup Node.js
37+
uses: actions/setup-node@v4
38+
with:
39+
node-version: '18'
40+
cache: 'yarn'
41+
42+
- name: Setup Docker
43+
uses: docker/setup-buildx-action@v2
44+
45+
- name: Install Yarn
46+
run: corepack enable
47+
48+
- name: Install dependencies
49+
run: yarn install
50+
51+
- name: Build library packages
52+
run: |
53+
yarn build:lib
54+
yarn workspace @vue-skuilder/mcp build
55+
yarn workspace @vue-skuilder/express build
56+
yarn workspace @vue-skuilder/cli build
57+
58+
- name: Install Cypress
59+
run: yarn workspace @vue-skuilder/cli cypress install
60+
61+
- name: Start CouchDB
62+
run: yarn couchdb:start
63+
64+
- name: Run try:init to create test project
65+
working-directory: packages/cli
66+
run: yarn try:init
67+
68+
- name: Start scaffolded app dev server and wait for services
69+
working-directory: packages/cli/testproject
70+
run: |
71+
# Start the dev server in background
72+
npm run dev &
73+
# Wait for the webserver to be ready
74+
npx wait-on http://localhost:5173 --timeout 60000
75+
76+
- name: Run E2E tests on scaffolded app
77+
working-directory: packages/cli
78+
run: yarn test:e2e:headless
79+
80+
- name: Cleanup services
81+
if: always()
82+
run: |
83+
# Clean up dev server
84+
kill $(lsof -t -i:5173) || true
85+
# Clean up CouchDB
86+
yarn couchdb:stop
87+
88+
- name: Upload screenshots on failure
89+
uses: actions/upload-artifact@v4
90+
if: failure()
91+
with:
92+
name: cypress-screenshots
93+
path: packages/cli/cypress/screenshots
94+
retention-days: 7
95+
96+
- name: Upload videos
97+
uses: actions/upload-artifact@v4
98+
if: always()
99+
with:
100+
name: cypress-videos
101+
path: packages/cli/cypress/videos
102+
retention-days: 7

packages/cli/cypress.config.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { defineConfig } from 'cypress';
2+
3+
export default defineConfig({
4+
e2e: {
5+
baseUrl: 'http://localhost:5173', // Default Vite dev server port
6+
supportFile: 'cypress/support/e2e.js',
7+
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
8+
setupNodeEvents(on, config) {
9+
// implement node event listeners here
10+
},
11+
},
12+
// Increase the default timeout for slower operations
13+
defaultCommandTimeout: 10000,
14+
// Viewport configuration
15+
viewportWidth: 1280,
16+
viewportHeight: 800,
17+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Test for CLI scaffolded application
2+
describe('CLI Scaffolded App - Study View', () => {
3+
it('should navigate to /study and render a card', () => {
4+
// Visit the study page
5+
cy.visit('/study');
6+
7+
// Wait for the application to load and render
8+
// Check for the presence of a card view
9+
// The cardView class is present on all rendered cards
10+
cy.get('.cardView', { timeout: 15000 })
11+
.should('exist')
12+
.and('be.visible');
13+
14+
// Additional validation: check that the card has a viewable data attribute
15+
// This indicates it's a properly rendered card component
16+
cy.get('[data-viewable]', { timeout: 15000 })
17+
.should('exist')
18+
.and('be.visible');
19+
20+
// Verify that the card container (v-card) is present
21+
cy.get('.v-card', { timeout: 15000 })
22+
.should('exist')
23+
.and('be.visible');
24+
});
25+
26+
it('should load the home page successfully', () => {
27+
cy.visit('/');
28+
29+
// Verify basic page load
30+
cy.get('body').should('be.visible');
31+
});
32+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// ***********************************************
2+
// This example commands.js shows you how to
3+
// create various custom commands and overwrite
4+
// existing commands.
5+
//
6+
// For more comprehensive examples of custom
7+
// commands please read more here:
8+
// https://on.cypress.io/custom-commands
9+
// ***********************************************
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// ***********************************************************
2+
// This example support/e2e.js is processed and
3+
// loaded automatically before your test files.
4+
//
5+
// This is a great place to put global configuration and
6+
// behavior that modifies Cypress.
7+
//
8+
// You can change the location of this file or turn off
9+
// automatically serving support files with the
10+
// 'supportFile' configuration option.
11+
//
12+
// You can read more here:
13+
// https://on.cypress.io/configuration
14+
// ***********************************************************
15+
16+
// Import commands.js using ES2015 syntax:
17+
import './commands';

packages/cli/package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626
"lint": "npx eslint .",
2727
"lint:fix": "npx eslint . --fix",
2828
"lint:check": "npx eslint . --max-warnings 0",
29-
"try:init": "node dist/cli.js init testproject --dangerously-clobber --no-interactive --data-layer static --import-course-data --import-server-url http://localhost:5984 --import-username admin --import-password password --import-course-ids 2aeb8315ef78f3e89ca386992d00825b && cd testproject && npm i && npm install --save-dev @vue-skuilder/cli@file:.. && npm install @vue-skuilder/db@file:../../db @vue-skuilder/courseware@file:../../courseware @vue-skuilder/common-ui@file:../../common-ui @vue-skuilder/express@file:../../express"
29+
"try:init": "node dist/cli.js init testproject --dangerously-clobber --no-interactive --data-layer static --import-course-data --import-server-url http://localhost:5984 --import-username admin --import-password password --import-course-ids 2aeb8315ef78f3e89ca386992d00825b && cd testproject && npm i && npm install --save-dev @vue-skuilder/cli@file:.. && npm install @vue-skuilder/db@file:../../db @vue-skuilder/courseware@file:../../courseware @vue-skuilder/common-ui@file:../../common-ui @vue-skuilder/express@file:../../express",
30+
"test:e2e": "cypress open",
31+
"test:e2e:headless": "cypress run"
3032
},
3133
"keywords": [
3234
"cli",
@@ -64,9 +66,11 @@
6466
"@types/serve-static": "^1.15.0",
6567
"@vitejs/plugin-vue": "^5.2.1",
6668
"@vue-skuilder/studio-ui": "workspace:*",
69+
"cypress": "14.1.0",
6770
"typescript": "~5.7.2",
6871
"vite": "^6.0.9",
69-
"vue-tsc": "^1.8.0"
72+
"vue-tsc": "^1.8.0",
73+
"wait-on": "8.0.2"
7074
},
7175
"engines": {
7276
"node": ">=18.0.0"

packages/cli/src/utils/pack-courses.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import chalk from 'chalk';
22
import path from 'path';
3+
import { promises as fs } from 'fs';
34

45
export interface PackCoursesOptions {
56
server: string;
@@ -39,6 +40,38 @@ export async function packCourses(options: PackCoursesOptions): Promise<void> {
3940
});
4041

4142
console.log(chalk.green(`✅ Successfully packed course: ${courseId}`));
43+
44+
// Create course-level skuilder.json for the packed course
45+
const coursePath = path.join(outputDir, courseId);
46+
const manifestPath = path.join(coursePath, 'manifest.json');
47+
48+
// Read the manifest to get course title
49+
let courseTitle = courseId;
50+
try {
51+
const manifestContent = await fs.readFile(manifestPath, 'utf-8');
52+
const manifest = JSON.parse(manifestContent);
53+
courseTitle = manifest.courseName || manifest.courseConfig?.name || courseId;
54+
} catch (error) {
55+
console.warn(chalk.yellow(`⚠️ Could not read manifest for course title, using courseId`));
56+
}
57+
58+
// Create course-level skuilder.json
59+
const courseSkuilderJson = {
60+
name: `@skuilder/course-${courseId}`,
61+
version: '1.0.0',
62+
description: courseTitle,
63+
content: {
64+
type: 'static',
65+
manifest: './manifest.json',
66+
},
67+
};
68+
69+
await fs.writeFile(
70+
path.join(coursePath, 'skuilder.json'),
71+
JSON.stringify(courseSkuilderJson, null, 2)
72+
);
73+
74+
console.log(chalk.gray(`📄 Created skuilder.json for course: ${courseId}`));
4275
} catch (error: unknown) {
4376
console.error(chalk.red(`❌ Failed to pack course ${courseId}:`));
4477
console.error(chalk.red(error instanceof Error ? error.message : String(error)));

packages/cli/src/utils/template.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,35 @@ export async function generateSkuilderConfig(
289289

290290
await fs.writeFile(configPath, JSON.stringify(skuilderConfig, null, 2));
291291

292+
// For static data layer, create root skuilder.json manifest
293+
if (config.dataLayerType === 'static' && outputPath && skuilderConfig.course) {
294+
const publicPath = path.join(outputPath, 'public');
295+
await fs.mkdir(publicPath, { recursive: true });
296+
297+
// Determine course IDs to include in dependencies
298+
const courseIds = config.importCourseIds && config.importCourseIds.length > 0
299+
? config.importCourseIds
300+
: [skuilderConfig.course];
301+
302+
// Create root skuilder.json with course dependencies
303+
const rootManifest = {
304+
name: `@skuilder/${config.projectName}`,
305+
version: '1.0.0',
306+
description: config.title,
307+
dependencies: Object.fromEntries(
308+
courseIds.map((courseId) => [
309+
`@skuilder/course-${courseId}`,
310+
`/static-courses/${courseId}/skuilder.json`,
311+
])
312+
),
313+
};
314+
315+
await fs.writeFile(
316+
path.join(publicPath, 'skuilder.json'),
317+
JSON.stringify(rootManifest, null, 2)
318+
);
319+
}
320+
292321
// For static data layer without imports, create empty course structure
293322
if (
294323
config.dataLayerType === 'static' &&
@@ -393,6 +422,22 @@ async function createEmptyCourseStructure(
393422

394423
await fs.writeFile(path.join(coursePath, 'manifest.json'), JSON.stringify(manifest, null, 2));
395424

425+
// Create course-level skuilder.json (points to manifest.json)
426+
const courseSkuilderJson = {
427+
name: `@skuilder/course-${courseId}`,
428+
version: '1.0.0',
429+
description: title,
430+
content: {
431+
type: 'static',
432+
manifest: './manifest.json',
433+
},
434+
};
435+
436+
await fs.writeFile(
437+
path.join(coursePath, 'skuilder.json'),
438+
JSON.stringify(courseSkuilderJson, null, 2)
439+
);
440+
396441
// Create empty tags index
397442
await fs.writeFile(
398443
path.join(coursePath, 'indices', 'tags.json'),

packages/db/src/impl/static/StaticDataLayerProvider.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,18 @@ export class StaticDataLayerProvider implements DataLayerProvider {
7070
throw new Error(`Failed to fetch final content manifest for ${courseName} at ${finalManifestUrl}`);
7171
}
7272
const finalManifest = await finalManifestResponse.json();
73-
74-
this.manifests[courseName] = finalManifest;
73+
74+
// Extract courseId from the manifest to use as the lookup key
75+
const courseId = finalManifest.courseId || finalManifest.courseConfig?.courseID;
76+
if (!courseId) {
77+
throw new Error(`Course manifest for ${courseName} missing courseId`);
78+
}
79+
80+
this.manifests[courseId] = finalManifest;
7581
const unpacker = new StaticDataUnpacker(finalManifest, baseUrl);
76-
this.courseUnpackers.set(courseName, unpacker);
82+
this.courseUnpackers.set(courseId, unpacker);
7783

78-
logger.info(`[StaticDataLayerProvider] Successfully resolved and prepared course: ${courseName}`);
84+
logger.info(`[StaticDataLayerProvider] Successfully resolved and prepared course: ${courseName} (courseId: ${courseId})`);
7985
}
8086
} catch (e) {
8187
logger.error(`[StaticDataLayerProvider] Failed to resolve dependency ${courseName}:`, e);

packages/standalone-ui/src/main.ts

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -42,32 +42,25 @@ import config from '../skuilder.config.json';
4242
};
4343

4444
if (config.dataLayerType === 'static') {
45-
// Load manifest for static mode
46-
const courseId = config.course;
47-
if (!courseId) {
48-
throw new Error('Course ID required for static data layer');
49-
}
50-
45+
// Load root skuilder.json manifest for static mode
5146
try {
52-
const manifestResponse = await fetch(`/static-courses/${courseId}/manifest.json`);
53-
if (!manifestResponse.ok) {
47+
const rootManifestUrl = '/skuilder.json';
48+
const rootManifestResponse = await fetch(rootManifestUrl);
49+
if (!rootManifestResponse.ok) {
5450
throw new Error(
55-
`Failed to load manifest: ${manifestResponse.status} ${manifestResponse.statusText}`
51+
`Failed to load root manifest: ${rootManifestResponse.status} ${rootManifestResponse.statusText}`
5652
);
5753
}
58-
const manifest = await manifestResponse.json();
59-
console.log(`Loaded manifest for course ${courseId}`);
60-
console.log(JSON.stringify(manifest));
54+
const rootManifest = await rootManifestResponse.json();
55+
console.log('[DEBUG] Loaded root manifest:', rootManifest);
6156

6257
dataLayerOptions = {
63-
staticContentPath: '/static-courses',
64-
manifests: {
65-
[courseId]: manifest,
66-
},
58+
rootManifest,
59+
rootManifestUrl: new URL(rootManifestUrl, window.location.href).href,
6760
};
6861
} catch (error) {
69-
console.error('[DEBUG] Failed to load course manifest:', error);
70-
throw new Error(`Could not load course manifest for ${courseId}: ${error}`);
62+
console.error('[DEBUG] Failed to load root manifest:', error);
63+
throw new Error(`Could not load root skuilder.json manifest: ${error}`);
7164
}
7265
}
7366

0 commit comments

Comments
 (0)