Skip to content

Commit 22bd7f9

Browse files
Add experimental xapi support
1 parent c010d35 commit 22bd7f9

5 files changed

Lines changed: 294 additions & 11 deletions

File tree

dist/index.js

Lines changed: 26 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,15 @@
2020
"scripts": {
2121
"postinstall": "npx puppeteer browsers install chrome",
2222
"__preinstall": "cd LiaScript && npm i && npm run build:scorm1.2 && cp -r dist ../assets/scorm1.2",
23-
"build:assets": "npm run clean && npm run asset:scorm1.2 && npm run asset:scorm2004 && npm run asset:web && npm run asset:indexeddb && npm run asset:pdf && npm run asset:capacitor && npm run asset:logo && npm run fix:file",
23+
"build:assets": "npm run clean && npm run asset:scorm1.2 && npm run asset:scorm2004 && npm run asset:xapi && npm run asset:web && npm run asset:indexeddb && npm run asset:pdf && npm run asset:capacitor && npm run asset:logo && npm run fix:file",
2424
"clean": "rm -rf dist/assets/*",
2525
"asset:logo": "cp -r LiaScript/resources dist",
2626
"asset:web": "cd LiaScript && npm i && npm run build:base && cp -r dist ../dist/assets/web",
2727
"asset:pdf": "cd LiaScript && git checkout feat/fullPage && npm i && npm run build:pdf && cp -r dist ../dist/assets/pdf && git checkout development",
2828
"asset:scorm1.2": "cd LiaScript && npm i && npm run build:scorm1.2 && cp -r dist ../dist/assets/scorm1.2",
2929
"asset:scorm2004": "cd LiaScript && npm i && npm run build:scorm2004 && cp -r dist ../dist/assets/scorm2004",
3030
"asset:indexeddb": "cd LiaScript && npm i && npm run build:indexeddb && cp -r dist ../dist/assets/indexeddb",
31+
"asset:xapi": "cd LiaScript && npm i && npm run build:xapi && cp -r dist ../dist/assets/xapi",
3132
"asset:capacitor": "cd LiaScript && git checkout feat/capacitor && rm -rf node_modules && npm i && npm run build:android && cp -r dist ../dist/assets/capacitor && git checkout development && rm -rf node_modules && npm i",
3233
"build": "npx parcel build --no-cache --no-source-maps src/index.ts && npm run shebang",
3334
"build:debug": "npx parcel build --target node --no-minify --log-level 5 src/index.ts",

src/export/xapi.ts

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import * as helper from './helper'
2+
import * as RDF from './rdf'
3+
4+
const path = require('path')
5+
const fs = require('fs-extra')
6+
7+
/**
8+
* Help function for xAPI export
9+
*/
10+
export function help() {
11+
console.log('\nxAPI Options:')
12+
console.log(
13+
'--xapi-endpoint URL of the Learning Record Store (LRS) endpoint'
14+
)
15+
console.log(
16+
'--xapi-auth Authentication string for the LRS (e.g., "Basic dXNlcm5hbWU6cGFzc3dvcmQ=")'
17+
)
18+
console.log(
19+
'--xapi-actor JSON string representing the xAPI actor (default: anonymous)'
20+
)
21+
console.log(
22+
'--xapi-course-id Custom identifier for the course (default: course URL)'
23+
)
24+
console.log(
25+
'--xapi-course-title Custom title for the course (default: from document)'
26+
)
27+
console.log('--xapi-debug Enable debug logging for xAPI statements')
28+
console.log('--xapi-zip Package the output as a zip file')
29+
}
30+
31+
/**
32+
* Generate tincan.xml file for xAPI package
33+
* @param courseTitle The title of the course
34+
* @param courseDescription The description of the course
35+
* @param launchFile The HTML file to launch (usually index.html)
36+
* @param courseId The unique identifier for the course
37+
* @param resources Array of resource files to include in the package
38+
* @returns XML string for tincan.xml
39+
*/
40+
function generateTincanXml(
41+
courseTitle: string,
42+
courseDescription: string,
43+
launchFile: string,
44+
courseId: string,
45+
resources: string[] = []
46+
) {
47+
// Create resource elements for all files
48+
const resourceElements = resources
49+
.map((resource) => {
50+
return ` <resource lang="en-us">${resource}</resource>`
51+
})
52+
.join('\n')
53+
54+
return `<?xml version="1.0" encoding="utf-8" ?>
55+
<tincan xmlns="http://projecttincan.com/tincan.xsd">
56+
<activities>
57+
<activity id="${courseId}" type="course">
58+
<name>${courseTitle}</name>
59+
<description lang="en-US">${courseDescription}</description>
60+
<launch lang="en-us">${launchFile}</launch>
61+
${resourceElements}
62+
</activity>
63+
</activities>
64+
</tincan>`
65+
}
66+
67+
export async function exporter(
68+
argument: {
69+
input: string
70+
readme: string
71+
output: string
72+
format: string
73+
path: string
74+
key?: string
75+
style?: string
76+
'xapi-endpoint'?: string
77+
'xapi-auth'?: string
78+
'xapi-actor'?: string
79+
'xapi-course-id'?: string
80+
'xapi-course-title'?: string
81+
'xapi-debug'?: boolean
82+
'xapi-zip'?: boolean
83+
},
84+
json: any
85+
) {
86+
// make temp folder
87+
let tmp = await helper.tmpDir()
88+
const dirname = helper.dirname()
89+
let tmpPath = path.join(tmp, 'pro')
90+
91+
// copy assets to temp
92+
await fs.copy(path.join(dirname, './assets/xapi'), tmpPath)
93+
94+
// copy base path or readme-directory into temp
95+
await fs.copy(argument.path, tmpPath)
96+
97+
// Read and modify index.html
98+
let index = fs.readFileSync(path.join(tmpPath, 'index.html'), 'utf8')
99+
100+
// Change responsive key
101+
if (argument.key) {
102+
index = helper.injectResponsivevoice(argument.key, index)
103+
}
104+
105+
// Add default course
106+
index = helper.inject(
107+
`<script>
108+
if (!window.LIA) {
109+
window.LIA = {}
110+
}
111+
window.LIA.defaultCourseURL = "${path.basename(argument.readme)}"
112+
</script>`,
113+
index
114+
)
115+
116+
// Add xAPI configuration
117+
const xapiConfig = {
118+
endpoint: argument['xapi-endpoint'] || '',
119+
auth: argument['xapi-auth'] || '',
120+
actor: argument['xapi-actor']
121+
? JSON.parse(argument['xapi-actor'])
122+
: {
123+
objectType: 'Agent',
124+
name: 'Anonymous',
125+
mbox: 'mailto:anonymous@example.com',
126+
},
127+
courseId: argument['xapi-course-id'] || '',
128+
courseTitle:
129+
argument['xapi-course-title'] || json.lia.str_title || 'LiaScript Course',
130+
debug: argument['xapi-debug'] || false,
131+
}
132+
133+
index = helper.inject(
134+
`<script>
135+
window.xAPIConfig = ${JSON.stringify(xapiConfig, null, 2)};
136+
</script>`,
137+
index
138+
)
139+
140+
// Update title
141+
try {
142+
index = index.replace(
143+
'<title>Lia</title>',
144+
`<title>${json.lia.str_title}</title><meta property="og:title" content="${json.lia.str_title}"> <meta name="twitter:title" content="${json.lia.str_title}">`
145+
)
146+
console.log('updating title ...')
147+
} catch (e) {
148+
console.warn('could not add title')
149+
}
150+
151+
// Add description
152+
try {
153+
let description = json.lia.definition.macro.comment
154+
index = index.replace(
155+
'<meta name="description" content="LiaScript is a service for running free and interactive online courses, build with its own Markup-language. So check out the following course ;-)">',
156+
`<meta name="description" content="${description}"><meta property="og:description" content="${description}"><meta name="twitter:description" content="${description}">`
157+
)
158+
console.log('updating description ...')
159+
} catch (e) {
160+
console.warn('could not add description')
161+
}
162+
163+
// Add logo
164+
try {
165+
let logo = json.lia.definition.logo
166+
index = helper.inject(
167+
`<meta property="og:image" content="${logo}"><meta name="twitter:image" content="${logo}">`,
168+
index
169+
)
170+
console.log('updating logo ...')
171+
} catch (e) {
172+
console.warn('could not add image')
173+
}
174+
175+
// Add JSON-LD
176+
const jsonLD = await RDF.script(argument, json)
177+
178+
try {
179+
index = helper.inject(jsonLD, index)
180+
index = helper.prettify(index)
181+
await helper.writeFile(path.join(tmpPath, 'index.html'), index)
182+
} catch (e) {
183+
console.warn(e)
184+
return
185+
}
186+
187+
// Find all resources in the package
188+
const getAllFiles = function (dirPath: string, arrayOfFiles: string[] = []) {
189+
const files = fs.readdirSync(dirPath)
190+
191+
files.forEach(function (file) {
192+
if (fs.statSync(path.join(dirPath, file)).isDirectory()) {
193+
arrayOfFiles = getAllFiles(path.join(dirPath, file), arrayOfFiles)
194+
} else {
195+
// Get path relative to tmpPath
196+
const relativePath = path.relative(tmpPath, path.join(dirPath, file))
197+
// Only include files, not directories, and exclude tincan.xml itself
198+
if (relativePath && relativePath !== 'tincan.xml') {
199+
arrayOfFiles.push(relativePath)
200+
}
201+
}
202+
})
203+
204+
return arrayOfFiles
205+
}
206+
207+
const resources = getAllFiles(tmpPath)
208+
209+
// Generate tincan.xml file
210+
const courseTitle = json.lia.str_title || 'LiaScript Course'
211+
const courseDescription =
212+
json.lia.definition?.macro?.comment || 'A LiaScript course'
213+
const courseId =
214+
argument['xapi-course-id'] ||
215+
`https://liascript.github.io/course/${helper.random(12)}`
216+
const tincanXml = generateTincanXml(
217+
courseTitle,
218+
courseDescription,
219+
'index.html',
220+
courseId,
221+
resources
222+
)
223+
224+
// Write tincan.xml to the root of the package
225+
await helper.writeFile(path.join(tmpPath, 'tincan.xml'), tincanXml)
226+
227+
// Create zip or move to output
228+
if (argument['xapi-zip']) {
229+
// Always create a zip for xAPI packages
230+
// Ensure output directory's parent exists
231+
const outputParent = path.dirname(argument.output)
232+
await fs.ensureDir(outputParent)
233+
234+
// Create zip file
235+
helper.zip(tmpPath, argument.output)
236+
} else {
237+
// Ensure output directory's parent exists
238+
const outputParent = path.dirname(argument.output)
239+
await fs.ensureDir(outputParent)
240+
241+
// Move files from temp to output
242+
await fs.move(tmpPath, argument.output, {
243+
filter: helper.filterHidden(argument.path),
244+
overwrite: true,
245+
})
246+
}
247+
}

src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as ANDROID from './export/android'
1111
// import * as IOS from './export/ios'
1212
import * as PROJECT from './export/project'
1313
import * as RDF from './export/rdf'
14+
import * as XAPI from './export/xapi'
1415

1516
import * as COLOR from './colorize'
1617

@@ -109,6 +110,11 @@ async function run(argument) {
109110
ANDROID.exporter(argument, JSON.parse(string))
110111
break
111112
}
113+
// Add xAPI case to the switch statement in the output.subscribe function
114+
case 'xapi': {
115+
XAPI.exporter(argument, JSON.parse(string))
116+
break
117+
}
112118
case 'project': {
113119
if (collection) {
114120
try {
@@ -152,6 +158,7 @@ async function run(argument) {
152158
argument.format == 'android' ||
153159
argument.format == 'ios' ||
154160
argument.format == 'rdf' ||
161+
argument.format == 'xapi' ||
155162
argument.format == 'project'
156163
? 'fulljson'
157164
: argument.format
@@ -240,6 +247,8 @@ function help() {
240247
PROJECT.help()
241248

242249
RDF.help()
250+
251+
XAPI.help()
243252
}
244253

245254
function escapeBackslash(path?: string) {
@@ -326,6 +335,15 @@ function parseArguments() {
326335
'rdf-license': argv['rdf-license'],
327336
'rdf-educationalLevel': argv['rdf-educationalLevel'],
328337
'rdf-template': argv['rdf-template'],
338+
339+
// xAPI settings
340+
'xapi-endpoint': argv['xapi-endpoint'],
341+
'xapi-auth': argv['xapi-auth'],
342+
'xapi-actor': argv['xapi-actor'],
343+
'xapi-course-id': argv['xapi-course-id'],
344+
'xapi-course-title': argv['xapi-course-title'],
345+
'xapi-debug': argv['xapi-debug'],
346+
'xapi-zip': argv['xapi-zip'],
329347
}
330348

331349
argument.format = argument.format.toLowerCase()

0 commit comments

Comments
 (0)