Skip to content

Commit 27523fd

Browse files
authored
Toware skuilder.json config standard (#897)
- **use skuilder.json for multi-course config** - **move responibility for skuilder.json parsing to db** - **add def for SkuilderManifest** PR introduces `skuilder.json` general config for declaring course contents and dependencies. Test usage in the docs site inlining skuilder demo course.
2 parents 9d75843 + 84b9ac9 commit 27523fd

File tree

8 files changed

+185
-83
lines changed

8 files changed

+185
-83
lines changed

docs/.vitepress/theme/components/EmbeddedCourse.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,13 @@ interface Props {
8686
}
8787
8888
const props = withDefaults(defineProps<Props>(), {
89-
courseId: '2aeb8315ef78f3e89ca386992d00825b',
89+
courseId: '@skuilder/hero-course',
9090
sessionTimeLimit: 10,
9191
sessionConfig: () => ({ likesConfetti: true })
9292
});
9393
9494
// Data layer composable
95-
const { dataLayer, error: dataLayerError, isLoading: dataLayerLoading, initialize } = useStaticDataLayer([props.courseId]);
95+
const { dataLayer, error: dataLayerError, isLoading: dataLayerLoading, initialize } = useStaticDataLayer();
9696
9797
// Component state
9898
const sessionPrepared = ref(false);
@@ -188,7 +188,7 @@ const initializeSession = async () => {
188188
189189
// Ensure data layer is initialized
190190
if (!dataLayer.value) {
191-
await initialize();
191+
await initialize(props.courseId);
192192
}
193193
194194
if (!dataLayer.value) {

docs/.vitepress/theme/components/HeroStudySession.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<template>
33
<div class="hero-study-session">
44
<EmbeddedCourse
5-
course-id="2aeb8315ef78f3e89ca386992d00825b"
5+
course-id="@skuilder/hero-course"
66
:session-time-limit="5"
77
:session-config="{ likesConfetti: true }"
88
/>

docs/.vitepress/theme/composables/useStaticDataLayer.ts

Lines changed: 37 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type DataLayerConfig,
77
initializeDataLayer
88
} from '@vue-skuilder/db';
9+
import { withBase } from 'vitepress';
910

1011
interface UseStaticDataLayerReturn {
1112
dataLayer: Ref<DataLayerProvider | null>;
@@ -14,98 +15,74 @@ interface UseStaticDataLayerReturn {
1415
initialize: () => Promise<void>;
1516
}
1617

18+
// Cache the dataLayer promise to prevent re-initialization across components
19+
let dataLayerPromise: Promise<DataLayerProvider> | null = null;
20+
1721
/**
18-
* Composable for initializing static data layer in VitePress docs context
19-
* Provides localStorage persistence and error handling
22+
* Composable for initializing a static data layer in VitePress.
23+
* It fetches the root skuilder.json manifest and passes it to the DataLayerProvider.
2024
*/
21-
export function useStaticDataLayer(courseIds: string[] = ['2aeb8315ef78f3e89ca386992d00825b']): UseStaticDataLayerReturn {
25+
export function useStaticDataLayer(): UseStaticDataLayerReturn {
2226
const dataLayer = ref<DataLayerProvider | null>(null);
2327
const error = ref<Error | null>(null);
2428
const isLoading = ref(false);
2529

26-
// Calculate relative path to static-courses from current location
27-
const getStaticCoursesPath = (): string => {
28-
// Get current path depth to calculate relative path back to root
29-
const currentPath = window.location.pathname;
30-
const basePath = '/vue-skuilder/';
31-
32-
// Remove base path to get relative path within docs
33-
const relativePath = currentPath.replace(basePath, '');
34-
const pathDepth = relativePath.split('/').filter(segment => segment.length > 0).length - 1;
35-
36-
// Build relative path back to root: '../' repeated pathDepth times + 'static-courses'
37-
const backToRoot = pathDepth > 0 ? '../'.repeat(pathDepth) : './';
38-
const staticCoursesPath = `${backToRoot}static-courses`;
39-
40-
console.log(`[useStaticDataLayer] Current path: ${currentPath}, depth: ${pathDepth}, static path: ${staticCoursesPath}`);
41-
return staticCoursesPath;
42-
};
43-
4430
const initialize = async (): Promise<void> => {
4531
if (dataLayer.value) {
46-
console.log('[useStaticDataLayer] Already initialized, skipping');
32+
console.log('[useStaticDataLayer] Data layer already available, skipping initialization.');
33+
return;
34+
}
35+
36+
// If another component is already initializing, just wait for its promise
37+
if (dataLayerPromise) {
38+
console.log('[useStaticDataLayer] Initialization already in progress, awaiting result...');
39+
isLoading.value = true;
40+
try {
41+
dataLayer.value = await dataLayerPromise;
42+
} catch (e) {
43+
error.value = e as Error;
44+
} finally {
45+
isLoading.value = false;
46+
}
4747
return;
4848
}
4949

5050
try {
5151
isLoading.value = true;
5252
error.value = null;
5353

54-
console.log('[useStaticDataLayer] Initializing with course IDs:', courseIds);
55-
56-
// Get the correct relative path to static-courses
57-
const staticCoursesPath = getStaticCoursesPath();
54+
console.log('[useStaticDataLayer] Starting data layer initialization...');
5855

59-
// Load individual manifests for each course (following testproject pattern)
60-
const manifests: Record<string, any> = {};
61-
62-
for (const courseId of courseIds) {
63-
try {
64-
console.log(`[useStaticDataLayer] Loading manifest for course: ${courseId}`);
65-
const manifestResponse = await fetch(`${staticCoursesPath}/${courseId}/manifest.json`);
66-
67-
if (!manifestResponse.ok) {
68-
throw new Error(`Failed to load manifest: ${manifestResponse.status} ${manifestResponse.statusText}`);
69-
}
70-
71-
const manifest = await manifestResponse.json();
72-
manifests[courseId] = manifest;
73-
console.log(`[useStaticDataLayer] Loaded manifest for course ${courseId}:`, manifest.courseName || 'Unknown');
74-
75-
} catch (manifestError) {
76-
console.error(`[useStaticDataLayer] Failed to load manifest for course ${courseId}:`, manifestError);
77-
throw new Error(`Could not load course manifest for ${courseId}: ${manifestError}`);
78-
}
56+
// 1. Fetch the root application manifest
57+
const rootManifestUrl = withBase('/skuilder.json');
58+
const rootManifestResponse = await fetch(rootManifestUrl);
59+
if (!rootManifestResponse.ok) {
60+
throw new Error(`Failed to fetch root manifest: ${rootManifestUrl}`);
7961
}
62+
const rootManifest = await rootManifestResponse.json();
8063

81-
console.log('[useStaticDataLayer] All manifests loaded:', Object.keys(manifests));
82-
83-
// Configure static data layer (following testproject pattern)
64+
// 2. Prepare the simplified config for the DataLayerProvider
8465
const config: DataLayerConfig = {
8566
type: 'static',
8667
options: {
87-
staticContentPath: staticCoursesPath,
88-
manifests,
89-
COURSE_IDS: courseIds
68+
rootManifest,
69+
rootManifestUrl: new URL(rootManifestUrl, window.location.href).href
9070
}
9171
};
9272

93-
console.log('[useStaticDataLayer] Initializing data layer with config');
73+
console.log('[useStaticDataLayer] Initializing data layer with root manifest.');
9474

95-
// Initialize the data layer
96-
dataLayer.value = await initializeDataLayer(config);
75+
// 3. Initialize the data layer and cache the promise
76+
dataLayerPromise = initializeDataLayer(config);
77+
dataLayer.value = await dataLayerPromise;
9778

9879
console.log('[useStaticDataLayer] Data layer initialized successfully');
9980

10081
} catch (e) {
10182
const err = e as Error;
10283
error.value = err;
84+
dataLayerPromise = null; // Clear promise on failure
10385
console.error('[useStaticDataLayer] Failed to initialize data layer:', err);
104-
console.error('[useStaticDataLayer] Error details:', {
105-
message: err.message,
106-
stack: err.stack,
107-
courseIds,
108-
});
10986
} finally {
11087
isLoading.value = false;
11188
}

docs/dbg/embedded-course-test.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ Test page for EmbeddedCourse component development and debugging.
66
import EmbeddedCourse from '../.vitepress/theme/components/EmbeddedCourse.vue'
77
</script>
88

9-
## EmbeddedCourse Component Test
9+
## Embedded "local" course
1010

11-
<EmbeddedCourse course-id="2aeb8315ef78f3e89ca386992d00825b" :session-time-limit="5" />
11+
<EmbeddedCourse course-id="@skuilder/hero-course" :session-time-limit="5" />
12+
13+
14+
## Embedded "remote" course
15+
16+
This is loaded from a static deployed course from another repo at https://patched-network.github.io/demo-chess
17+
18+
NB: it is not running content for a chess course, but a repackaging of the golang course.
19+
20+
<EmbeddedCourse course-id="@skuilder/external-test" :session-time-limit="5" />

docs/public/skuilder.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "@skuilder/docs-site",
3+
"version": "0.1.0",
4+
"description": "The root manifest for the vue-skuilder documentation site.",
5+
"dependencies": {
6+
"@skuilder/hero-course": "/vue-skuilder/static-courses/2aeb8315ef78f3e89ca386992d00825b/skuilder.json",
7+
"@skuilder/external-test": "https://patched-network.github.io/demo-chess/static-courses/2aeb8315ef78f3e89ca386992d00825b/skuilder.json"
8+
}
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "@skuilder/hero-course",
3+
"version": "1.0.0",
4+
"description": "The interactive course used in the VitePress hero section.",
5+
"content": {
6+
"type": "static",
7+
"manifest": "./manifest.json"
8+
}
9+
}

docs/skuilder.json.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
2+
# `skuilder.json` Manifest (WIP)
3+
4+
The `skuilder.json` file is a declarative manifest used to define a Skuilder project or a composable content package (a "quilt"). It is inspired by `package.json` from the Node.js ecosystem, allowing content to be managed as packages with their own metadata and dependencies.
5+
6+
## Current Usage (MVP)
7+
8+
In the current implementation, `skuilder.json` enables the documentation site to load multiple courses from different locations using a runtime resolution strategy.
9+
10+
There are two primary ways this file is used:
11+
12+
### 1. Root Manifest (Aggregator)
13+
14+
An application (like this docs site) has a root `skuilder.json` file that declares its content dependencies. The `dependencies` field maps a package name to the URL of its own `skuilder.json`.
15+
16+
**Example: `/public/skuilder.json`**
17+
18+
```json
19+
{
20+
"name": "@skuilder/docs-site",
21+
"version": "0.1.0",
22+
"description": "The root manifest for the vue-skuilder documentation site.",
23+
"dependencies": {
24+
"@skuilder/hero-course": "/vue-skuilder/static-courses/2aeb8315ef78f3e89ca386992d00825b/skuilder.json",
25+
"@skuilder/example-course-1": "https://patched-network.github.io/example-course-1/skuilder.json"
26+
}
27+
}
28+
```
29+
30+
### 2. Leaf Manifest (Content Package)
31+
32+
A self-contained course (a "leaf" package) has its own `skuilder.json` that defines its metadata and points to its content manifest.
33+
34+
**Example: A `skuilder.json` within a course directory**
35+
36+
```json
37+
{
38+
"name": "@skuilder/hero-course",
39+
"version": "1.0.0",
40+
"description": "The interactive course used in the VitePress hero section.",
41+
"content": {
42+
"type": "static",
43+
"manifest": "./manifest.json"
44+
}
45+
}
46+
```
47+
48+
At runtime, the data layer starts by fetching the root manifest, then recursively fetches the manifest for each dependency to discover the location of the actual course content.
49+
50+
## Roadmap
51+
52+
This runtime resolution is the first step. The long-term vision is to create a more robust system analogous to modern package managers.
53+
54+
- **Build-Time Dependency Resolution:** A CLI command (e.g., `skuilder install`) will be introduced. This tool will read the root `skuilder.json` and resolve the entire dependency graph ahead of time.
55+
56+
- **Lock File Generation:** The resolution step will produce a `skuilder.lock.json` file. This file will contain a flat, deterministic list of all required course manifests and their absolute URLs. The runtime data layer will consume this simple lock file directly, resulting in faster and more reliable startup.
57+
58+
- **Registry Support:** Eventually, the system will support version-based dependencies (e.g., `"@skuilder/math-basics": "^1.2.0"`) which will be resolved against a central or private Skuilder package registry.

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

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
12
// packages/db/src/impl/static/StaticDataLayerProvider.ts
23

34
import {
@@ -17,39 +18,78 @@ import { StaticCoursesDB } from './coursesDB';
1718
import { BaseUser } from '../common';
1819
import { NoOpSyncStrategy } from './NoOpSyncStrategy';
1920

21+
22+
interface SkuilderManifest {
23+
name?: string;
24+
version?: string;
25+
description?: string;
26+
dependencies?: Record<string, string>;
27+
}
28+
2029
interface StaticDataLayerConfig {
21-
staticContentPath: string;
2230
localStoragePrefix?: string;
23-
manifests: Record<string, StaticCourseManifest>; // courseId -> manifest
31+
rootManifest: SkuilderManifest; // The parsed root skuilder.json object
32+
rootManifestUrl: string; // The absolute URL where the root manifest was found
2433
}
2534

2635
export class StaticDataLayerProvider implements DataLayerProvider {
2736
private config: StaticDataLayerConfig;
2837
private initialized: boolean = false;
2938
private courseUnpackers: Map<string, StaticDataUnpacker> = new Map();
39+
private manifests: Record<string, StaticCourseManifest> = {};
3040

3141
constructor(config: Partial<StaticDataLayerConfig>) {
3242
this.config = {
33-
staticContentPath: config.staticContentPath || '/static-courses',
3443
localStoragePrefix: config.localStoragePrefix || 'skuilder-static',
35-
manifests: config.manifests || {},
44+
rootManifest: config.rootManifest || { dependencies: {} },
45+
rootManifestUrl: config.rootManifestUrl || '/',
3646
};
3747
}
3848

49+
private async resolveCourseDependencies(): Promise<void> {
50+
logger.info('[StaticDataLayerProvider] Starting course dependency resolution...');
51+
const rootManifest = this.config.rootManifest;
52+
53+
for (const [courseName, courseUrl] of Object.entries(rootManifest.dependencies || {})) {
54+
try {
55+
logger.debug(`[StaticDataLayerProvider] Resolving dependency: ${courseName} from ${courseUrl}`);
56+
57+
const courseManifestUrl = new URL(courseUrl as string, this.config.rootManifestUrl).href;
58+
const courseJsonResponse = await fetch(courseManifestUrl);
59+
if (!courseJsonResponse.ok) {
60+
throw new Error(`Failed to fetch course manifest for ${courseName}`);
61+
}
62+
const courseJson = await courseJsonResponse.json();
63+
64+
if (courseJson.content && courseJson.content.manifest) {
65+
const baseUrl = new URL('.', courseManifestUrl).href;
66+
const finalManifestUrl = new URL(courseJson.content.manifest, courseManifestUrl).href;
67+
68+
const finalManifestResponse = await fetch(finalManifestUrl);
69+
if (!finalManifestResponse.ok) {
70+
throw new Error(`Failed to fetch final content manifest for ${courseName} at ${finalManifestUrl}`);
71+
}
72+
const finalManifest = await finalManifestResponse.json();
73+
74+
this.manifests[courseName] = finalManifest;
75+
const unpacker = new StaticDataUnpacker(finalManifest, baseUrl);
76+
this.courseUnpackers.set(courseName, unpacker);
77+
78+
logger.info(`[StaticDataLayerProvider] Successfully resolved and prepared course: ${courseName}`);
79+
}
80+
} catch (e) {
81+
logger.error(`[StaticDataLayerProvider] Failed to resolve dependency ${courseName}:`, e);
82+
// Continue to next dependency
83+
}
84+
}
85+
logger.info('[StaticDataLayerProvider] Course dependency resolution complete.');
86+
}
87+
3988
async initialize(): Promise<void> {
4089
if (this.initialized) return;
4190

4291
logger.info('Initializing static data layer provider');
43-
44-
// Load manifests for all courses
45-
for (const [courseId, manifest] of Object.entries(this.config.manifests)) {
46-
const unpacker = new StaticDataUnpacker(
47-
manifest,
48-
`${this.config.staticContentPath}/${courseId}`
49-
);
50-
this.courseUnpackers.set(courseId, unpacker);
51-
}
52-
92+
await this.resolveCourseDependencies();
5393
this.initialized = true;
5494
}
5595

@@ -67,14 +107,14 @@ export class StaticDataLayerProvider implements DataLayerProvider {
67107
getCourseDB(courseId: string): CourseDBInterface {
68108
const unpacker = this.courseUnpackers.get(courseId);
69109
if (!unpacker) {
70-
throw new Error(`Course ${courseId} not found in static data`);
110+
throw new Error(`Course ${courseId} not found or failed to initialize in static data layer.`);
71111
}
72-
const manifest = this.config.manifests[courseId];
112+
const manifest = this.manifests[courseId];
73113
return new StaticCourseDB(courseId, unpacker, this.getUserDB(), manifest);
74114
}
75115

76116
getCoursesDB(): CoursesDBInterface {
77-
return new StaticCoursesDB(this.config.manifests);
117+
return new StaticCoursesDB(this.manifests);
78118
}
79119

80120
async getClassroomDB(

0 commit comments

Comments
 (0)