Skip to content

Commit 9e09ed9

Browse files
Copilotjodeleeuw
andcommitted
Enhance example handling: extract trials and generate interactive demo HTML files
Co-authored-by: jodeleeuw <595524+jodeleeuw@users.noreply.github.com>
1 parent 5787c93 commit 9e09ed9

2 files changed

Lines changed: 272 additions & 15 deletions

File tree

docs/developers/documentation.md

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,25 @@ Run the command `poetry install` in the root directory of jsPsych to install `mk
1414

1515
## Generating plugin documentation
1616

17-
Plugin documentation can be automatically generated from the JSDoc comments in plugin source files. The generator extracts:
17+
Plugin documentation can be automatically generated from the JSDoc comments in plugin source files and examples in the `/examples` folder. The generator extracts:
1818

1919
- Plugin description from the class JSDoc comment
2020
- Parameter information from the `info.parameters` object
2121
- Data field information from the `info.data` object
22-
- Example file references from the `examples/` directory
22+
- Individual trial configurations from the example files in `/examples`
23+
24+
The generator converts examples into the documentation format with interactive Code/Demo tabs, eliminating the need to maintain separate example sets.
2325

2426
To generate documentation for a single plugin:
2527

2628
```bash
2729
npm run generate-plugin-docs -- html-button-response
2830
```
2931

30-
To generate documentation for all plugins:
32+
To generate documentation for all plugins with demo HTML files:
3133

3234
```bash
33-
npm run generate-plugin-docs -- --all --output docs/plugins
35+
npm run generate-plugin-docs -- --all --output docs/plugins --demos-output docs/demos
3436
```
3537

3638
To see all available options:
@@ -39,7 +41,19 @@ To see all available options:
3941
npm run generate-plugin-docs -- --help
4042
```
4143

42-
The generated documentation follows the same format as the existing plugin docs, including parameters tables, data generated tables, and install instructions.
44+
Options:
45+
46+
- `--output, -o`: Directory for generated markdown files
47+
- `--demos-output, -d`: Directory for generated demo HTML files
48+
- `--all`: Generate docs for all plugins
49+
- `--list`: List all available plugins
50+
51+
The generated documentation follows the same format as the existing plugin docs, including:
52+
53+
- Parameters tables
54+
- Data generated tables
55+
- Install instructions
56+
- Examples with Code/Demo tabs that embed interactive demos
4357

4458
## Building a local copy of the docs
4559

packages/config/generatePluginDocs.js

Lines changed: 253 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,19 @@ const parameterTypeMap = {
3737
TIMELINE: "object",
3838
};
3939

40+
/**
41+
* Convert plugin name (kebab-case) to jsPsych variable name (e.g., html-button-response -> jsPsychHtmlButtonResponse)
42+
*/
43+
function pluginNameToVarName(pluginName) {
44+
return (
45+
"jsPsych" +
46+
pluginName
47+
.split("-")
48+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
49+
.join("")
50+
);
51+
}
52+
4053
/**
4154
* Find the matching closing brace for content starting after an opening brace
4255
*/
@@ -337,6 +350,170 @@ function extractExampleCode(htmlContent) {
337350
return null;
338351
}
339352

353+
/**
354+
* Normalizes the indentation of code to use consistent 2-space indentation.
355+
*/
356+
function normalizeCodeIndentation(code) {
357+
const lines = code.split("\n");
358+
359+
// Find the minimum indentation (excluding empty lines and the first line which should start with {)
360+
let minIndent = Infinity;
361+
for (let i = 1; i < lines.length; i++) {
362+
const line = lines[i];
363+
if (line.trim()) {
364+
const leadingSpaces = line.match(/^(\s*)/)[1].length;
365+
minIndent = Math.min(minIndent, leadingSpaces);
366+
}
367+
}
368+
369+
if (minIndent === Infinity) minIndent = 0;
370+
371+
// Remove the common indentation and normalize
372+
const normalizedLines = lines.map((line, idx) => {
373+
if (!line.trim()) return "";
374+
if (idx === 0) return line.trim(); // First line (opening brace)
375+
return line.substring(minIndent);
376+
});
377+
378+
// Format into a proper object literal with consistent indentation
379+
let result = normalizedLines.join("\n").trim();
380+
381+
// Try to reformat as a proper JavaScript object
382+
// Remove extra indentation before closing brace
383+
result = result.replace(/\n\s*\}$/, "\n}");
384+
385+
return result;
386+
}
387+
388+
/**
389+
* Extracts individual trial configurations from example code.
390+
* Returns an array of trial objects with their code and a description derived from the prompt or comments.
391+
*/
392+
function extractTrialsFromExampleCode(code, pluginName) {
393+
const trials = [];
394+
395+
const pluginVarName = pluginNameToVarName(pluginName);
396+
397+
// Look for trial objects that use this plugin type
398+
// Pattern matches: { type: jsPsychPluginName, ... } or timeline.push({ type: jsPsychPluginName, ... })
399+
// Note: This regex handles up to 2 levels of nested objects which covers most trial configurations
400+
const trialPattern = new RegExp(
401+
`(?:(?:const|let|var)\\s+(\\w+)\\s*=\\s*)?\\{[^{}]*type\\s*:\\s*${pluginVarName}[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}`,
402+
"g"
403+
);
404+
405+
let match;
406+
while ((match = trialPattern.exec(code)) !== null) {
407+
let trialCode = match[0];
408+
409+
// If the trial was assigned to a variable, use just the object literal
410+
if (match[1]) {
411+
// Extract just the object part
412+
const objStart = trialCode.indexOf("{");
413+
trialCode = trialCode.substring(objStart);
414+
}
415+
416+
// Try to extract a description from the prompt parameter or nearby comments
417+
let description = null;
418+
const promptMatch = trialCode.match(/prompt\s*:\s*["'`]([^"'`]+)["'`]/);
419+
if (promptMatch) {
420+
// Strip HTML tags from the prompt to get plain text for the example title
421+
// Note: This is processing trusted internal example files, not user input
422+
description = promptMatch[1].replace(/<[^>]*>/g, "").trim();
423+
}
424+
425+
// Clean up and normalize the trial code formatting
426+
trialCode = normalizeCodeIndentation(trialCode);
427+
428+
trials.push({
429+
code: trialCode,
430+
description: description || `Example ${trials.length + 1}`,
431+
});
432+
}
433+
434+
return trials;
435+
}
436+
437+
/**
438+
* Generates a demo HTML file for embedding in documentation.
439+
* The demo file uses CDN-hosted scripts and the docs-demo-timeline wrapper.
440+
*/
441+
function generateDemoHtml(trialCode, pluginName, jspsychVersion, pluginVersion) {
442+
const packageName = `@jspsych/plugin-${pluginName}`;
443+
444+
// Note: The demo HTML files depend on docs-demo-timeline.js and docs-demo.css
445+
// which should already exist in docs/demos/ from the existing documentation build.
446+
// These files provide the interactive "Run demo" / "Repeat demo" wrapper functionality.
447+
448+
return `<!DOCTYPE html>
449+
<html>
450+
<head>
451+
<script src="docs-demo-timeline.js"></script>
452+
<script src="https://unpkg.com/jspsych@${jspsychVersion}"></script>
453+
<script src="https://unpkg.com/${packageName}@${pluginVersion}"></script>
454+
<link rel="stylesheet" href="https://unpkg.com/jspsych@${jspsychVersion}/css/jspsych.css" />
455+
<link rel="stylesheet" href="docs-demo.css" type="text/css">
456+
</head>
457+
<body></body>
458+
<script>
459+
460+
const jsPsych = initJsPsych();
461+
462+
const timeline = [];
463+
464+
const trial = ${trialCode};
465+
466+
timeline.push(trial);
467+
468+
if (typeof jsPsych !== "undefined") {
469+
jsPsych.run(generateDocsDemoTimeline(timeline));
470+
} else {
471+
document.body.innerHTML = '<div style="text-align:center; margin-top:50%; transform:translate(0,-50%);">You must be online to view the plugin demo.</div>';
472+
}
473+
</script>
474+
</html>
475+
`;
476+
}
477+
478+
/**
479+
* Generates the examples section markdown with Code/Demo tabs.
480+
*/
481+
function generateExamplesSection(trials, pluginName, demoBasePath) {
482+
if (trials.length === 0) {
483+
return "";
484+
}
485+
486+
let markdown = "\n## Examples\n\n";
487+
488+
trials.forEach((trial, index) => {
489+
const demoNum = index + 1;
490+
const demoFileName = `jspsych-${pluginName}-demo${demoNum}.html`;
491+
492+
markdown += `???+ example "${trial.description}"\n`;
493+
markdown += ` === "Code"\n`;
494+
markdown += ` \`\`\`javascript\n`;
495+
496+
// Indent the code properly for the markdown
497+
const indentedCode = trial.code
498+
.split("\n")
499+
.map((line) => ` ${line}`)
500+
.join("\n");
501+
markdown += indentedCode + "\n";
502+
503+
markdown += ` \`\`\`\n`;
504+
markdown += `\n`;
505+
markdown += ` === "Demo"\n`;
506+
markdown += ` <div style="text-align:center;">\n`;
507+
markdown += ` <iframe src="${demoBasePath}${demoFileName}" width="90%;" height="600px;" frameBorder="0"></iframe>\n`;
508+
markdown += ` </div>\n`;
509+
markdown += `\n`;
510+
markdown += ` <a target="_blank" rel="noopener noreferrer" href="${demoBasePath}${demoFileName}">Open demo in new tab</a>\n`;
511+
markdown += `\n`;
512+
});
513+
514+
return markdown;
515+
}
516+
340517
/**
341518
* Generates the parameters table markdown
342519
*/
@@ -465,8 +642,44 @@ ${generateDataTable(data)}
465642
${generateInstallSection(pluginName, version || "latest")}
466643
`;
467644

468-
// Add examples section if examples exist
469-
if (examples.length > 0) {
645+
// Extract trials from examples and generate examples section
646+
const extractedTrials = [];
647+
const demoFiles = [];
648+
649+
// Get jsPsych version for demo files
650+
const jspsychPackagePath = join(rootDir, "packages", "jspsych", "package.json");
651+
const jspsychVersion = existsSync(jspsychPackagePath)
652+
? JSON.parse(readFileSync(jspsychPackagePath, "utf8")).version
653+
: "latest";
654+
655+
for (const example of examples) {
656+
const exampleCode = extractExampleCode(example.content);
657+
if (exampleCode) {
658+
const trials = extractTrialsFromExampleCode(exampleCode, pluginName);
659+
for (const trial of trials) {
660+
extractedTrials.push(trial);
661+
662+
// Generate demo HTML file content
663+
const demoHtml = generateDemoHtml(
664+
trial.code,
665+
pluginName,
666+
jspsychVersion,
667+
version || "latest"
668+
);
669+
const demoFileName = `jspsych-${pluginName}-demo${extractedTrials.length}.html`;
670+
demoFiles.push({
671+
fileName: demoFileName,
672+
content: demoHtml,
673+
});
674+
}
675+
}
676+
}
677+
678+
// Add examples section with Code/Demo tabs if trials were extracted
679+
if (extractedTrials.length > 0) {
680+
markdown += generateExamplesSection(extractedTrials, pluginName, "../../demos/");
681+
} else if (examples.length > 0) {
682+
// Fallback to simple link if no trials could be extracted
470683
markdown += `\n## Examples\n\n`;
471684
markdown += `See example file${examples.length > 1 ? "s" : ""} in the [examples folder](https://github.com/jspsych/jsPsych/tree/main/examples) for usage demonstrations.\n`;
472685
}
@@ -479,6 +692,8 @@ ${generateInstallSection(pluginName, version || "latest")}
479692
data,
480693
description,
481694
examplesCount: examples.length,
695+
extractedTrials,
696+
demoFiles,
482697
};
483698
}
484699

@@ -520,14 +735,15 @@ function main() {
520735
Usage: node generatePluginDocs.js [options] [plugin-name...]
521736
522737
Options:
523-
--help, -h Show this help message
524-
--all Generate docs for all plugins
525-
--output, -o Output directory (default: stdout)
526-
--list List all available plugins
738+
--help, -h Show this help message
739+
--all Generate docs for all plugins
740+
--output, -o Output directory for markdown files (default: stdout)
741+
--demos-output, -d Output directory for demo HTML files
742+
--list List all available plugins
527743
528744
Examples:
529745
node generatePluginDocs.js html-button-response
530-
node generatePluginDocs.js --all --output docs/plugins
746+
node generatePluginDocs.js --all --output docs/plugins --demos-output docs/demos
531747
node generatePluginDocs.js --list
532748
`);
533749
return;
@@ -545,22 +761,36 @@ Examples:
545761
if (args.includes("--all")) {
546762
pluginNames = getAllPluginNames();
547763
} else {
548-
pluginNames = args.filter((arg) => !arg.startsWith("-"));
764+
// Filter out flag arguments and their values
765+
const flagsWithValues = ["--output", "-o", "--demos-output", "-d"];
766+
const skipNext = new Set();
767+
for (let i = 0; i < args.length; i++) {
768+
if (flagsWithValues.includes(args[i])) {
769+
skipNext.add(i + 1);
770+
}
771+
}
772+
pluginNames = args.filter((arg, idx) => !arg.startsWith("-") && !skipNext.has(idx));
549773
}
550774

551775
const outputDir = getArgValue(args, "--output", "-o");
776+
const demosOutputDir = getArgValue(args, "--demos-output", "-d");
552777

553778
if (pluginNames.length === 0) {
554779
console.error("No plugins specified. Use --help for usage information.");
555780
process.exit(1);
556781
}
557782

558-
// Create output directory if specified and doesn't exist
783+
// Create output directories if specified and don't exist
559784
if (outputDir && !existsSync(outputDir)) {
560785
mkdirSync(outputDir, { recursive: true });
561786
console.error(`Created output directory: ${outputDir}`);
562787
}
563788

789+
if (demosOutputDir && !existsSync(demosOutputDir)) {
790+
mkdirSync(demosOutputDir, { recursive: true });
791+
console.error(`Created demos output directory: ${demosOutputDir}`);
792+
}
793+
564794
for (const pluginName of pluginNames) {
565795
console.error(`Generating documentation for: ${pluginName}`);
566796

@@ -575,8 +805,17 @@ Examples:
575805
console.log(result.markdown);
576806
}
577807

808+
// Write demo files if demos output directory is specified
809+
if (demosOutputDir && result.demoFiles && result.demoFiles.length > 0) {
810+
for (const demoFile of result.demoFiles) {
811+
const demoPath = join(demosOutputDir, demoFile.fileName);
812+
writeFileSync(demoPath, demoFile.content);
813+
console.error(` Demo written to: ${demoPath}`);
814+
}
815+
}
816+
578817
console.error(
579-
` Parameters: ${result.parameters.length}, Data fields: ${result.data.length}, Examples: ${result.examplesCount}`
818+
` Parameters: ${result.parameters.length}, Data fields: ${result.data.length}, Examples: ${result.examplesCount}, Extracted trials: ${result.extractedTrials ? result.extractedTrials.length : 0}`
580819
);
581820
}
582821
}
@@ -589,6 +828,10 @@ export {
589828
extractPluginDescription,
590829
parseParametersSection,
591830
extractPluginInfo,
831+
extractTrialsFromExampleCode,
832+
generateDemoHtml,
833+
generateExamplesSection,
834+
pluginNameToVarName,
592835
parameterTypeMap,
593836
};
594837

0 commit comments

Comments
 (0)