Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 36 additions & 12 deletions packages/autodoc/src/parsers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,14 +234,15 @@ export function getCodeBlock(
export function getExampleInfo(
sourcePath: string,
inferFallback: (content: string, path: string) => string,
): Record<string, ExampleInfo> | undefined {
): ExampleInfo | undefined {
const content = fs.readFileSync(sourcePath, "utf-8");

if (/<!--\s*jspsych-autodoc:ignore\s*-->/.test(content)) return undefined;

let title: string;

const sentinelMatch = content.match(/<!--\s*jspsych-autodoc:title\s+(.+?)\s*-->/);
const hasCustomTitle = !!sentinelMatch;
if (sentinelMatch) {
title = sentinelMatch[1].trim();
} else {
Expand All @@ -250,9 +251,7 @@ export function getExampleInfo(
title = titleTagMatch[1].trim();
}

return {
[title]: { path: sourcePath, code: getCodeBlock(content, sourcePath, inferFallback) },
};
return { title, hasCustomTitle, path: sourcePath, displayPath: sourcePath, code: getCodeBlock(content, sourcePath, inferFallback) };
}

/** Collects example info from a directory or single HTML file. */
Expand All @@ -266,14 +265,18 @@ export function collectExamples(

const stat = fs.statSync(examplePath);
const htmlFiles: string[] = [];
let isDirectory = false;

if (stat.isDirectory()) {
htmlFiles.push(
...fs
.readdirSync(examplePath)
.filter((f) => f.endsWith(".html"))
.map((f) => path.join(examplePath, f)),
);
isDirectory = true;
const collectHtml = (dir: string) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from what I understand, fs.readdirSync is unsorted (implementation differs across OSes) so whichever one of shared titles becoming the secondary one might get flipped. would be good to sort the array before looping over it

const full = path.join(dir, entry.name);
if (entry.isDirectory()) collectHtml(full);
else if (entry.name.endsWith(".html")) htmlFiles.push(full);
}
};
collectHtml(examplePath);
} else if (stat.isFile()) {
if (!examplePath.endsWith(".html")) {
throw new Error(`Example file must be an HTML file: ${examplePath}`);
Expand All @@ -284,9 +287,30 @@ export function collectExamples(
}

const result: Record<string, ExampleInfo> = {};
const titleCounts = new Map<string, number>();
for (const file of htmlFiles) {
const exampleInfo = getExampleInfo(file, inferFallback);
if (exampleInfo) Object.assign(result, exampleInfo);
try {
const info = getExampleInfo(file, inferFallback);
if (info) {
info.displayPath = isDirectory ? path.relative(examplePath, info.path) : path.basename(info.path);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if this ensures if paths could persist across OSes, thinking about windows using backslash (darn windows..), consider either using relativizeExamplePaths in the CLI utils.ts, or .split(path.sep).join("/") and then remove the relativizeExamplePaths call in the runCli function in cli.ts.

if (info.hasCustomTitle) {
const baseTitle = info.title;
const count = titleCounts.get(baseTitle) ?? 0;
titleCounts.set(baseTitle, count + 1);
if (count > 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it looks like this might not work for titles "Hello", "Hello (2)", "Hello", the paths are stored separately so it won't be clobbered but it will result in duplicate section headers once rendered out. not sure if worth doing this edge case because it's not even that big of a deal, just flagging it

info.title = `${baseTitle} (${count + 1})`;
console.warn(`Warning: duplicate example title "${baseTitle}" in ${file}, renamed to "${info.title}"`);
}
}
result[info.path] = info;
}
} catch (e) {
if (htmlFiles.length > 1) {
console.warn(`Warning: skipping ${file}: ${e instanceof Error ? e.message : e}`);
} else {
throw e;
}
}
}
return result;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/autodoc/src/renderers/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ ${renderFunctionGroup(info.functions)}
if (Object.keys(info.examples).length === 0) return "";
const sections = Object.entries(info.examples)
.map(
([title, example]) =>
`### ${title} (${example.path})
([, example]) =>
`### ${example.title}${example.hasCustomTitle ? "" : ` (${example.displayPath})`}

\`\`\`js
${example.code}
Expand Down
4 changes: 2 additions & 2 deletions packages/autodoc/src/renderers/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ ${renderFunctionGroup(info.functions)}
if (Object.keys(info.examples).length === 0) return "";
const sections = Object.entries(info.examples)
.map(
([title, example]) =>
`### ${title} (${example.path})
([, example]) =>
`### ${example.title}${example.hasCustomTitle ? "" : ` (${example.displayPath})`}

\`\`\`js
${example.code}
Expand Down
4 changes: 2 additions & 2 deletions packages/autodoc/src/renderers/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ ${sections}`;
if (Object.keys(info.examples).length === 0) return "";
const sections = Object.entries(info.examples)
.map(
([title, example]) =>
`### ${title} (${example.path})
([, example]) =>
`### ${example.title}${example.hasCustomTitle ? "" : ` (${example.displayPath})`}

\`\`\`js
${example.code}
Expand Down
3 changes: 3 additions & 0 deletions packages/autodoc/src/types/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ export interface ParameterInfo {

/** name is attached via record */
export interface ExampleInfo {
title: string;
hasCustomTitle: boolean;
path: string;
displayPath: string;
code: string;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<title>TestExtension Example</title>
</head>
<body>
<script>
// jspsych-autodoc:start
const trial = { type: jsPsychTestPlugin, stimulus: "path1" };
// jspsych-autodoc:end
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<title>TestExtension Example</title>
</head>
<body>
<script>
// jspsych-autodoc:start
const trial = { type: jsPsychTestPlugin, stimulus: "path2" };
// jspsych-autodoc:end
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<!-- jspsych-autodoc:title my example -->
</head>
<body>
<script>
// jspsych-autodoc:start
const trial = { type: jsPsychTestPlugin, stimulus: "file a" };
// jspsych-autodoc:end
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<!-- jspsych-autodoc:title my example -->
</head>
<body>
<script>
// jspsych-autodoc:start
const trial = { type: jsPsychTestPlugin, stimulus: "file b" };
// jspsych-autodoc:end
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<title>My Example</title>
</head>
<body>
<script>
// jspsych-autodoc:start
const trial = { type: jsPsychTestPlugin, stimulus: "file a" };
// jspsych-autodoc:end
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<title>My Example</title>
</head>
<body>
<script>
// jspsych-autodoc:start
const trial = { type: jsPsychTestPlugin, stimulus: "file b" };
// jspsych-autodoc:end
</script>
</body>
</html>
88 changes: 70 additions & 18 deletions packages/autodoc/tests/parsers/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import ts from 'typescript';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { jest } from '@jest/globals';
import { identifyPackageType } from '../../src/utils.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -147,8 +148,8 @@ describe('inferCodeBlock (via getExtensionInfoAndExamples)', () => {
const filePath = path.join(inferTestsDir, 'non-trial-name-with-extension.html');
const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath);
expect(Object.keys(info.examples)).toHaveLength(1);
expect(info.examples['non trial name example']).toBeDefined();
const code = info.examples['non trial name example'].code;
expect(info.examples[filePath]).toBeDefined();
const code = info.examples[filePath].code;
expect(code).toContain('myBlock');
expect(code).toContain('initJsPsych');
});
Expand All @@ -157,8 +158,8 @@ describe('inferCodeBlock (via getExtensionInfoAndExamples)', () => {
const filePath = path.join(inferTestsDir, 'trial-name-with-extension.html');
const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath);
expect(Object.keys(info.examples)).toHaveLength(1);
expect(info.examples['trial name example']).toBeDefined();
const code = info.examples['trial name example'].code;
expect(info.examples[filePath]).toBeDefined();
const code = info.examples[filePath].code;
expect(code).toContain('const trial');
expect((code.match(/const trial\b/g) ?? []).length).toBe(1);
});
Expand All @@ -167,16 +168,16 @@ describe('inferCodeBlock (via getExtensionInfoAndExamples)', () => {
const filePath = path.join(inferTestsDir, 'trial-name-no-extension.html');
const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath);
expect(Object.keys(info.examples)).toHaveLength(1);
expect(info.examples['trial name no extension example']).toBeDefined();
const code = info.examples['trial name no extension example'].code;
expect(info.examples[filePath]).toBeDefined();
const code = info.examples[filePath].code;
expect(code).toContain('const trial');
expect(code).toContain('initJsPsych');
});

it('collects local dependencies of a trial found by name (no extensions property)', () => {
const filePath = path.join(inferTestsDir, 'trial-name-no-extension.html');
const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath);
const code = info.examples['trial name no extension example'].code;
const code = info.examples[filePath].code;
expect(code).toContain('const stimulus');
expect(code.indexOf('const stimulus')).toBeLessThan(code.indexOf('const trial'));
});
Expand All @@ -185,7 +186,7 @@ describe('inferCodeBlock (via getExtensionInfoAndExamples)', () => {
const filePath = path.join(inferTestsDir, 'mixed-detection.html');
const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath);
expect(Object.keys(info.examples)).toHaveLength(1);
const code = info.examples['mixed detection example'].code;
const code = info.examples[filePath].code;
expect(code).toContain('const trial');
expect(code).toContain('const myBlock');
});
Expand All @@ -194,7 +195,7 @@ describe('inferCodeBlock (via getExtensionInfoAndExamples)', () => {
const filePath = path.join(inferTestsDir, 'trial-name-non-object.html');
const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath);
expect(Object.keys(info.examples)).toHaveLength(1);
const code = info.examples['trial name non object example'].code;
const code = info.examples[filePath].code;
expect(code).toContain('const trial');
expect(code).toContain('"experiment stimulus text"');
});
Expand All @@ -203,7 +204,7 @@ describe('inferCodeBlock (via getExtensionInfoAndExamples)', () => {
const filePath = path.join(inferTestsDir, 'sentinel-bypass.html');
const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath);
expect(Object.keys(info.examples)).toHaveLength(1);
expect(info.examples['sentinel bypass example']).toBeDefined();
expect(info.examples[filePath]).toBeDefined();
});
});

Expand All @@ -215,38 +216,89 @@ describe('getExtensionInfoAndExamples', () => {
const { mainNode: classNode } = identifyPackageType(fixtureSource);
const info = getExtensionInfoAndExamples(fixtureSource, classNode as ts.ClassDeclaration, filePath);
expect(Object.keys(info.examples)).toHaveLength(1);
expect(info.examples['simple sentinel example']).toBeDefined();
expect(info.examples['simple sentinel example'].path).toBe(filePath);
expect(info.examples['simple sentinel example'].code).toBe(
expect(info.examples[filePath]).toBeDefined();
expect(info.examples[filePath].title).toBe('simple sentinel example');
expect(info.examples[filePath].path).toBe(filePath);
expect(info.examples[filePath].code).toBe(
'var trial = {\n type: jsPsychTestPlugin,\n stimulus: "hello",\n extensions: [\n {type: jsPsychTestExtension, params: {test: "hi"}}\n ]\n};'
);
});

it('produces unique titles when two files share the same jspsych-autodoc:title sentinel', () => {
const collisionDir = path.resolve(__dirname, '../fixtures/extension/title-sentinel-collision-tests');
const { mainNode: classNode } = identifyPackageType(fixtureSource);
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const info = getExtensionInfoAndExamples(fixtureSource, classNode as ts.ClassDeclaration, collisionDir);
const titles = Object.values(info.examples).map((e) => e.title);
expect(titles).toHaveLength(2);
expect(new Set(titles).size).toBe(2);
expect(titles[0]).toBe("my example");
expect(titles[1]).toBe("my example (2)");
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('my example'));
warnSpy.mockRestore();
});

it('keeps both examples when two files share the same <title> tag without renaming', () => {
const collisionDir = path.resolve(__dirname, '../fixtures/extension/title-tag-collision-tests');
const { mainNode: classNode } = identifyPackageType(fixtureSource);
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const info = getExtensionInfoAndExamples(fixtureSource, classNode as ts.ClassDeclaration, collisionDir);
const examples = Object.values(info.examples);
expect(examples).toHaveLength(2);
// titles are NOT renamed — displayPath in the heading handles uniqueness at render time
expect(examples.every((e) => e.title === 'My Example')).toBe(true);
const displayPaths = examples.map((e) => e.displayPath);
// Example headings will be unique due to the inclusion of file names/paths
expect(new Set(displayPaths).size).toBe(2);
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});

it('keeps both examples when two files have the same default title but different paths', () => {
const collisionDir = path.resolve(__dirname, '../fixtures/extension/title-filepath-collision-tests');
const { mainNode: classNode } = identifyPackageType(fixtureSource);
const info = getExtensionInfoAndExamples(fixtureSource, classNode as ts.ClassDeclaration, collisionDir);
expect(Object.values(info.examples)).toHaveLength(2);
const displayPaths = Object.values(info.examples).map((e) => e.displayPath);
expect(displayPaths).toContain('path1/duplicated_filename.html');
expect(displayPaths).toContain('path2/duplicated_filename.html');
const titles = Object.values(info.examples).map((e) => e.title);
expect(titles).toHaveLength(2);
// duplicate titles remain the same — source/displayPath appended at render time keeps the full headings unique
expect(new Set(titles).size).toBe(1);
expect(titles[0]).toBe('TestExtension Example');
expect(titles[1]).toBe('TestExtension Example');
});

it('should extract examples from a provided directory', () => {
const { mainNode: classNode } = identifyPackageType(fixtureSource);
const info = getExtensionInfoAndExamples(fixtureSource, classNode as ts.ClassDeclaration, examplesDir);
expect(Object.keys(info.examples)).toHaveLength(4);
expect(info.examples['ignored example']).toBeUndefined();
expect(info.examples[path.join(examplesDir, 'ignored-example.html')]).toBeUndefined();

const simpleSentinelExample = info.examples['simple sentinel example'];
const simpleSentinelExample = info.examples[path.join(examplesDir, 'simple-sentinel-example.html')];
expect(simpleSentinelExample.title).toBe('simple sentinel example');
expect(simpleSentinelExample.path).toBe(path.join(examplesDir, 'simple-sentinel-example.html'));
expect(simpleSentinelExample.code).toBe(
'var trial = {\n type: jsPsychTestPlugin,\n stimulus: "hello",\n extensions: [\n {type: jsPsychTestExtension, params: {test: "hi"}}\n ]\n};'
);

const complexSentinelExample = info.examples['complex sentinel example'];
const complexSentinelExample = info.examples[path.join(examplesDir, 'complex-sentinel-example.html')];
expect(complexSentinelExample.title).toBe('complex sentinel example');
expect(complexSentinelExample.path).toBe(path.join(examplesDir, 'complex-sentinel-example.html'));
expect(complexSentinelExample.code).toBe(
'var jsPsych = initJsPsych({\n extensions: [\n {type: jsPsychTestExtension}\n ]\n});\n\nvar helloTrial = {\n type: jsPsychTestPlugin,\n stimulus: "Hello",\n extensions: [\n {type: jsPsychTestExtension, params: {test: "hi"}}\n ]\n};\n\nvar goodbyeTrial = {\n type: jsPsychTestPlugin,\n stimulus: "Goodbye",\n extensions: [\n {type: jsPsychTestExtension, params: {test: "bye"}}\n ]\n};'
);

const simpleInferredExample = info.examples['simple inferred example'];
const simpleInferredExample = info.examples[path.join(examplesDir, 'simple-inferred-example.html')];
expect(simpleInferredExample.title).toBe('simple inferred example');
expect(simpleInferredExample.path).toBe(path.join(examplesDir, 'simple-inferred-example.html'));
expect(simpleInferredExample.code).toBe(
'var jsPsych = initJsPsych({\n extensions: [\n {type: jsPsychTestExtension, params: {test: "inferred"}}\n ]\n});\n\nvar trial = {\n type: jsPsychTestPlugin,\n stimulus: "World",\n extensions: [\n {type: jsPsychTestExtension, params: {test: "trial-level inferred"}}\n ]\n};'
);

const complexInferredExample = info.examples['complex inferred example'];
const complexInferredExample = info.examples[path.join(examplesDir, 'complex-inferred-example.html')];
expect(complexInferredExample.title).toBe('complex inferred example');
expect(complexInferredExample.path).toBe(path.join(examplesDir, 'complex-inferred-example.html'));
expect(complexInferredExample.code).toBe(
'var jsPsych = initJsPsych();\n\nvar stimulus = "Hello, world!";\n\nvar duration = 1000;\n\nvar choices = ["f", "j"];\n\nvar trial = {\n type: jsPsychTestPlugin,\n stimulus: stimulus,\n trial_duration: duration,\n choices: choices,\n extensions: [\n {type: jsPsychTestExtension, params: {test: "inferred complex"}}\n ]\n};'
Expand Down
Loading
Loading