From e8b7d797d05571bf73997d200d396f7d32b26fe2 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Tue, 7 Jul 2026 12:10:46 -0700 Subject: [PATCH 01/10] rework example collection to prevent overwriting of multiple examples: getExampleInfo now just returns info object (no top-level file/example key) and adds hasCustomTitle flag; collectExamples keys example info by the file path (always unique), sets display path for title (default titles only), checks for and handles duplicate titles (warning, adds number to prevent overwritting), puts everything in try/catch --- packages/autodoc/src/parsers/utils.ts | 28 ++++++++++++++++----- packages/autodoc/src/renderers/extension.ts | 4 +-- packages/autodoc/src/renderers/plugin.ts | 4 +-- packages/autodoc/src/renderers/timeline.ts | 4 +-- packages/autodoc/src/types/info.ts | 3 +++ 5 files changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/autodoc/src/parsers/utils.ts b/packages/autodoc/src/parsers/utils.ts index 27fecf4..0de978e 100644 --- a/packages/autodoc/src/parsers/utils.ts +++ b/packages/autodoc/src/parsers/utils.ts @@ -225,7 +225,7 @@ export function getCodeBlock( export function getExampleInfo( sourcePath: string, inferFallback: (content: string, path: string) => string, -): Record | undefined { +): ExampleInfo | undefined { const content = fs.readFileSync(sourcePath, "utf-8"); if (//.test(content)) return undefined; @@ -233,6 +233,7 @@ export function getExampleInfo( let title: string; const sentinelMatch = content.match(//); + const hasCustomTitle = !!sentinelMatch; if (sentinelMatch) { title = sentinelMatch[1].trim(); } else { @@ -241,9 +242,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. */ @@ -257,8 +256,10 @@ export function collectExamples( const stat = fs.statSync(examplePath); const htmlFiles: string[] = []; + let isDirectory = false; if (stat.isDirectory()) { + isDirectory = true; htmlFiles.push( ...fs .readdirSync(examplePath) @@ -275,9 +276,24 @@ export function collectExamples( } const result: Record = {}; + const titleCounts = new Map(); 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); + const baseTitle = info.title; + const count = titleCounts.get(baseTitle) ?? 0; + titleCounts.set(baseTitle, count + 1); + if (count > 0) { + info.title = `${baseTitle} (${count + 1})`; + console.warn(`Warning: duplicate example title "${baseTitle}" in ${file}, renamed to "${info.title}"`); + } + result[info.path] = info; + } + } catch (e) { + console.warn(`Warning: skipping ${file}: ${e instanceof Error ? e.message : e}`); + } } return result; } \ No newline at end of file diff --git a/packages/autodoc/src/renderers/extension.ts b/packages/autodoc/src/renderers/extension.ts index 1c9630f..3eea7f1 100644 --- a/packages/autodoc/src/renderers/extension.ts +++ b/packages/autodoc/src/renderers/extension.ts @@ -86,8 +86,8 @@ ${rows ?? "*None*"} render: (info) => { const sections = Object.entries(info.examples) .map( - ([title, example]) => - `### ${title} (${example.path}) + ([, example]) => + `### ${example.title}${example.hasCustomTitle ? "" : ` (${example.displayPath})`} \`\`\`js ${example.code} diff --git a/packages/autodoc/src/renderers/plugin.ts b/packages/autodoc/src/renderers/plugin.ts index f5c2eb9..481d081 100644 --- a/packages/autodoc/src/renderers/plugin.ts +++ b/packages/autodoc/src/renderers/plugin.ts @@ -54,8 +54,8 @@ ${rows ?? "*None*"} render: (info) => { const sections = Object.entries(info.examples) .map( - ([title, example]) => - `### ${title} (${example.path}) + ([, example]) => + `### ${example.title}${example.hasCustomTitle ? "" : ` (${example.displayPath})`} \`\`\`js ${example.code} diff --git a/packages/autodoc/src/renderers/timeline.ts b/packages/autodoc/src/renderers/timeline.ts index 8e149a2..fe2c828 100644 --- a/packages/autodoc/src/renderers/timeline.ts +++ b/packages/autodoc/src/renderers/timeline.ts @@ -122,8 +122,8 @@ ${sections || "*None*"}`; render: (info) => { const sections = Object.entries(info.examples) .map( - ([title, example]) => - `### ${title} (${example.path}) + ([, example]) => + `### ${example.title}${example.hasCustomTitle ? "" : ` (${example.displayPath})`} \`\`\`js ${example.code} diff --git a/packages/autodoc/src/types/info.ts b/packages/autodoc/src/types/info.ts index ad21da6..f3129f5 100644 --- a/packages/autodoc/src/types/info.ts +++ b/packages/autodoc/src/types/info.ts @@ -56,7 +56,10 @@ export interface ParameterInfo { /** name is attached via record */ export interface ExampleInfo { + title: string; + hasCustomTitle: boolean; path: string; + displayPath: string; code: string; } From 773afe2f0aaf1c450986e33d1cfc55f1c8cd3207 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Tue, 7 Jul 2026 12:15:17 -0700 Subject: [PATCH 02/10] update tests to match modified output of collectExamples: examples are keyed by file path (always unique), adds title, hasCustomTitle, and displayPath to example value object --- packages/autodoc/tests/renderers/extension.test.ts | 2 +- packages/autodoc/tests/renderers/plugin.test.ts | 2 +- packages/autodoc/tests/renderers/timeline.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/autodoc/tests/renderers/extension.test.ts b/packages/autodoc/tests/renderers/extension.test.ts index cebf506..06a84aa 100644 --- a/packages/autodoc/tests/renderers/extension.test.ts +++ b/packages/autodoc/tests/renderers/extension.test.ts @@ -17,7 +17,7 @@ const info: ExtensionInfo = { samples: { type: "object", default: "", description: "Collected samples." }, }, examples: { - "Basic example": { path: "examples/basic.html", code: "initJsPsych({ extensions: [...] });" }, + "examples/basic.html": { title: "Basic example", hasCustomTitle: true, path: "examples/basic.html", displayPath: "basic.html", code: "initJsPsych({ extensions: [...] });" }, }, }; diff --git a/packages/autodoc/tests/renderers/plugin.test.ts b/packages/autodoc/tests/renderers/plugin.test.ts index f4dc526..38df794 100644 --- a/packages/autodoc/tests/renderers/plugin.test.ts +++ b/packages/autodoc/tests/renderers/plugin.test.ts @@ -17,7 +17,7 @@ const info: PluginInfo = { response: { type: "ParameterType.STRING", default: "", description: "The key pressed." }, }, examples: { - "Basic example": { path: "examples/basic.html", code: "const trial = { type: jsPsychTestPlugin };" }, + "examples/basic.html": { title: "Basic example", hasCustomTitle: true, path: "examples/basic.html", displayPath: "basic.html", code: "const trial = { type: jsPsychTestPlugin };" }, }, }; diff --git a/packages/autodoc/tests/renderers/timeline.test.ts b/packages/autodoc/tests/renderers/timeline.test.ts index 16711b6..fe0ee0e 100644 --- a/packages/autodoc/tests/renderers/timeline.test.ts +++ b/packages/autodoc/tests/renderers/timeline.test.ts @@ -17,7 +17,7 @@ const info: TimelineInfo = { utils: {}, interfaces: {}, examples: { - "Basic example": { path: "examples/basic.html", code: "createTimeline(jsPsych);" }, + "examples/basic.html": { title: "Basic example", hasCustomTitle: true, path: "examples/basic.html", displayPath: "basic.html", code: "createTimeline(jsPsych);" }, }, }; From 26ad661d3e667cbacf65883b94324c502c95f382 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Tue, 7 Jul 2026 12:20:54 -0700 Subject: [PATCH 03/10] update tests to access examples with path as key instead of title, add tests for title --- .../autodoc/tests/parsers/extension.test.ts | 21 ++++++++++++------- packages/autodoc/tests/parsers/plugin.test.ts | 21 ++++++++++++------- .../autodoc/tests/parsers/timeline.test.ts | 21 ++++++++++++------- .../autodoc/tests/renderers/plugin.test.ts | 2 +- 4 files changed, 40 insertions(+), 25 deletions(-) diff --git a/packages/autodoc/tests/parsers/extension.test.ts b/packages/autodoc/tests/parsers/extension.test.ts index 2230122..609c6d3 100644 --- a/packages/autodoc/tests/parsers/extension.test.ts +++ b/packages/autodoc/tests/parsers/extension.test.ts @@ -101,9 +101,10 @@ 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};' ); }); @@ -112,27 +113,31 @@ describe('getExtensionInfoAndExamples', () => { 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};' diff --git a/packages/autodoc/tests/parsers/plugin.test.ts b/packages/autodoc/tests/parsers/plugin.test.ts index 5cc95e0..5a4ae13 100644 --- a/packages/autodoc/tests/parsers/plugin.test.ts +++ b/packages/autodoc/tests/parsers/plugin.test.ts @@ -60,9 +60,10 @@ describe('getPluginInfoAndExamples', () => { const { mainNode: classNode } = identifyPackageType(fixtureSource); const info = getPluginInfoAndExamples(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};' ); }); @@ -71,27 +72,31 @@ describe('getPluginInfoAndExamples', () => { const { mainNode: classNode } = identifyPackageType(fixtureSource); const info = getPluginInfoAndExamples(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};' ); - 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 fixationTrial = {\n type: jsPsychTestPlugin,\n stimulus: "+"\n};\n\nvar stimulusTrial = {\n type: jsPsychTestPlugin,\n stimulus: "Hello"\n};\n\nvar feedbackTrial = {\n type: jsPsychTestPlugin,\n stimulus: "Correct!"\n};' ); - const inferredExample = info.examples['simple inferred example']; + const inferredExample = info.examples[path.join(examplesDir, 'simple-inferred-example.html')]; + expect(inferredExample.title).toBe('simple inferred example'); expect(inferredExample.path).toBe(path.join(examplesDir, 'simple-inferred-example.html')); expect(inferredExample.code).toBe( 'var trial = {\n type: jsPsychTestPlugin,\n stimulus: "World"\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 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};' diff --git a/packages/autodoc/tests/parsers/timeline.test.ts b/packages/autodoc/tests/parsers/timeline.test.ts index 37e18b7..b5f81f1 100644 --- a/packages/autodoc/tests/parsers/timeline.test.ts +++ b/packages/autodoc/tests/parsers/timeline.test.ts @@ -174,9 +174,10 @@ describe('getTimelineInfoAndExamples', () => { const filePath = path.join(examplesDir, 'simple-sentinel-example.html'); const info = getTimelineInfoAndExamples(fixturePath, 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( 'const config = {\n testParam: 1,\n testParam2: "hello hello"\n}\n\nconst timeline = jsPsychTestTimeline.createTimeline(jsPsych, config);' ); }); @@ -184,27 +185,31 @@ describe('getTimelineInfoAndExamples', () => { it('should extract examples from a provided directory', () => { const info = getTimelineInfoAndExamples(fixturePath, 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( 'const config = {\n testParam: 1,\n testParam2: "hello hello"\n}\n\nconst timeline = jsPsychTestTimeline.createTimeline(jsPsych, config);' ); - 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 fixationTrial = jsPsychTestTimeline.createFixationTrial("+");\n\nvar stimulusTrial = jsPsychTestTimeline.createStimulusTrial(\n "hello",\n 12,\n true\n)\n\nvar feedbackTrial = jsPsychTestTimeline.createFeedbackTrial(false);' ); - 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( 'const config = {\n testParam: 1,\n testParam2: "hello hello"\n}\n\nconst timeline = jsPsychTestTimeline.createTimeline(jsPsych, config);' ); - 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( 'const fixationConfig = {\n fixation: "+",\n duration: 1000,\n}\n\nconst fixationTrial = jsPsychTestTimeline.timelineUnits.createFixation(fixationConfig);\n\nconst stimulusConfig = {\n stimuli: ["hello", "cheese wheel"],\n duration: [250, 1000],\n}\n\nconst stimulusTrial = jsPsychTestTimeline.timelineUnits.createStimulus({\n ...stimulusConfig, \n reverse: true\n})\n\nlet feedbackMessage = "dog";\n\nconst feedbackTrial = jsPsychTestTimeline.timelineUnits.createFeedbackTrial({\n feedback: feedbackMessage,\n showCreature: jsPsychTestTimeline.utils.canShowCreature("maybe")\n})' diff --git a/packages/autodoc/tests/renderers/plugin.test.ts b/packages/autodoc/tests/renderers/plugin.test.ts index 38df794..42df110 100644 --- a/packages/autodoc/tests/renderers/plugin.test.ts +++ b/packages/autodoc/tests/renderers/plugin.test.ts @@ -36,7 +36,7 @@ describe("plugin renderer (default template)", () => { it("renders data rows and examples", () => { expect(docs.data).toContain("Response time in ms."); - expect(docs.examples).toContain("examples/basic.html"); + expect(docs.examples).toContain("Basic example"); }); it("maps ParameterType values to human-readable strings in the data table", () => { From 60ddc25ae293e1a0f8ecfbb994f7917f4c0cedf7 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Tue, 7 Jul 2026 12:21:59 -0700 Subject: [PATCH 04/10] update example headings in snapshots: file path is now only included with default example titles, not custom --- .../tests/renderers/__snapshots__/extension.test.ts.snap | 2 +- .../autodoc/tests/renderers/__snapshots__/plugin.test.ts.snap | 2 +- .../autodoc/tests/renderers/__snapshots__/timeline.test.ts.snap | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/autodoc/tests/renderers/__snapshots__/extension.test.ts.snap b/packages/autodoc/tests/renderers/__snapshots__/extension.test.ts.snap index ebef847..3d82019 100644 --- a/packages/autodoc/tests/renderers/__snapshots__/extension.test.ts.snap +++ b/packages/autodoc/tests/renderers/__snapshots__/extension.test.ts.snap @@ -12,7 +12,7 @@ exports[`extension renderer (default template) matches the rendered snapshot 1`] "examples": " ## Examples -### Basic example (examples/basic.html) +### Basic example \`\`\`js initJsPsych({ extensions: [...] }); diff --git a/packages/autodoc/tests/renderers/__snapshots__/plugin.test.ts.snap b/packages/autodoc/tests/renderers/__snapshots__/plugin.test.ts.snap index af0338a..2705974 100644 --- a/packages/autodoc/tests/renderers/__snapshots__/plugin.test.ts.snap +++ b/packages/autodoc/tests/renderers/__snapshots__/plugin.test.ts.snap @@ -15,7 +15,7 @@ In addition to the [default data collected by all plugins](https://www.jspsych.o "examples": " ## Examples -### Basic example (examples/basic.html) +### Basic example \`\`\`js const trial = { type: jsPsychTestPlugin }; diff --git a/packages/autodoc/tests/renderers/__snapshots__/timeline.test.ts.snap b/packages/autodoc/tests/renderers/__snapshots__/timeline.test.ts.snap index 8a1536b..62742aa 100644 --- a/packages/autodoc/tests/renderers/__snapshots__/timeline.test.ts.snap +++ b/packages/autodoc/tests/renderers/__snapshots__/timeline.test.ts.snap @@ -24,7 +24,7 @@ Builds the timeline. "examples": " ## Examples -### Basic example (examples/basic.html) +### Basic example \`\`\`js createTimeline(jsPsych); From 250952271b62bd034bbd1377954086088b859b0b Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Tue, 7 Jul 2026 14:48:31 -0700 Subject: [PATCH 05/10] add fixtures and test for duplicated titles set via sentinels --- .../title-sentinel-collision-tests/file-a.html | 13 +++++++++++++ .../title-sentinel-collision-tests/file-b.html | 13 +++++++++++++ packages/autodoc/tests/parsers/extension.test.ts | 15 +++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 packages/autodoc/tests/fixtures/extension/title-sentinel-collision-tests/file-a.html create mode 100644 packages/autodoc/tests/fixtures/extension/title-sentinel-collision-tests/file-b.html diff --git a/packages/autodoc/tests/fixtures/extension/title-sentinel-collision-tests/file-a.html b/packages/autodoc/tests/fixtures/extension/title-sentinel-collision-tests/file-a.html new file mode 100644 index 0000000..e37da2d --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/title-sentinel-collision-tests/file-a.html @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/title-sentinel-collision-tests/file-b.html b/packages/autodoc/tests/fixtures/extension/title-sentinel-collision-tests/file-b.html new file mode 100644 index 0000000..91b24be --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/title-sentinel-collision-tests/file-b.html @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/packages/autodoc/tests/parsers/extension.test.ts b/packages/autodoc/tests/parsers/extension.test.ts index 609c6d3..47d512b 100644 --- a/packages/autodoc/tests/parsers/extension.test.ts +++ b/packages/autodoc/tests/parsers/extension.test.ts @@ -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)); @@ -109,6 +110,20 @@ describe('getExtensionInfoAndExamples', () => { ); }); + 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('should extract examples from a provided directory', () => { const { mainNode: classNode } = identifyPackageType(fixtureSource); const info = getExtensionInfoAndExamples(fixtureSource, classNode as ts.ClassDeclaration, examplesDir); From 5a2d94c7ba4a1ce5e0c230abf92228d764d8160e Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Tue, 7 Jul 2026 14:49:57 -0700 Subject: [PATCH 06/10] add recursive directory scanning to example dir, add fixtures/tests for same title and file name but different paths --- packages/autodoc/src/parsers/utils.ts | 28 +++++++++++-------- .../path1/duplicated_filename.html | 13 +++++++++ .../path2/duplicated_filename.html | 13 +++++++++ .../autodoc/tests/parsers/extension.test.ts | 16 +++++++++++ .../autodoc/tests/renderers/extension.test.ts | 26 +++++++++++++++++ 5 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 packages/autodoc/tests/fixtures/extension/title-filepath-collision-tests/path1/duplicated_filename.html create mode 100644 packages/autodoc/tests/fixtures/extension/title-filepath-collision-tests/path2/duplicated_filename.html diff --git a/packages/autodoc/src/parsers/utils.ts b/packages/autodoc/src/parsers/utils.ts index 0de978e..d54dad4 100644 --- a/packages/autodoc/src/parsers/utils.ts +++ b/packages/autodoc/src/parsers/utils.ts @@ -260,12 +260,14 @@ export function collectExamples( if (stat.isDirectory()) { isDirectory = true; - htmlFiles.push( - ...fs - .readdirSync(examplePath) - .filter((f) => f.endsWith(".html")) - .map((f) => path.join(examplePath, f)), - ); + const collectHtml = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + 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}`); @@ -282,12 +284,14 @@ export function collectExamples( const info = getExampleInfo(file, inferFallback); if (info) { info.displayPath = isDirectory ? path.relative(examplePath, info.path) : path.basename(info.path); - const baseTitle = info.title; - const count = titleCounts.get(baseTitle) ?? 0; - titleCounts.set(baseTitle, count + 1); - if (count > 0) { - info.title = `${baseTitle} (${count + 1})`; - console.warn(`Warning: duplicate example title "${baseTitle}" in ${file}, renamed to "${info.title}"`); + if (info.hasCustomTitle) { + const baseTitle = info.title; + const count = titleCounts.get(baseTitle) ?? 0; + titleCounts.set(baseTitle, count + 1); + if (count > 0) { + info.title = `${baseTitle} (${count + 1})`; + console.warn(`Warning: duplicate example title "${baseTitle}" in ${file}, renamed to "${info.title}"`); + } } result[info.path] = info; } diff --git a/packages/autodoc/tests/fixtures/extension/title-filepath-collision-tests/path1/duplicated_filename.html b/packages/autodoc/tests/fixtures/extension/title-filepath-collision-tests/path1/duplicated_filename.html new file mode 100644 index 0000000..b93d42f --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/title-filepath-collision-tests/path1/duplicated_filename.html @@ -0,0 +1,13 @@ + + + + TestExtension Example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/title-filepath-collision-tests/path2/duplicated_filename.html b/packages/autodoc/tests/fixtures/extension/title-filepath-collision-tests/path2/duplicated_filename.html new file mode 100644 index 0000000..89d88f7 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/title-filepath-collision-tests/path2/duplicated_filename.html @@ -0,0 +1,13 @@ + + + + TestExtension Example + + + + + diff --git a/packages/autodoc/tests/parsers/extension.test.ts b/packages/autodoc/tests/parsers/extension.test.ts index 47d512b..c2f8612 100644 --- a/packages/autodoc/tests/parsers/extension.test.ts +++ b/packages/autodoc/tests/parsers/extension.test.ts @@ -124,6 +124,22 @@ describe('getExtensionInfoAndExamples', () => { 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); diff --git a/packages/autodoc/tests/renderers/extension.test.ts b/packages/autodoc/tests/renderers/extension.test.ts index 06a84aa..e5638e9 100644 --- a/packages/autodoc/tests/renderers/extension.test.ts +++ b/packages/autodoc/tests/renderers/extension.test.ts @@ -47,6 +47,32 @@ describe("extension renderer (default template)", () => { expect(docs["data"]).not.toContain("ParameterType"); }); + it("renders both examples with their subdirectory path in the section heading", () => { + const infoWithPathCollision: ExtensionInfo = { + ...info, + examples: { + "examples/path1/duplicated_filename.html": { + title: "TestExtension Example", + hasCustomTitle: false, + path: "examples/path1/duplicated_filename.html", + displayPath: "path1/duplicated_filename.html", + code: "const trial1 = {};", + }, + "examples/path2/duplicated_filename.html": { + title: "TestExtension Example", + hasCustomTitle: false, + path: "examples/path2/duplicated_filename.html", + displayPath: "path2/duplicated_filename.html", + code: "const trial2 = {};", + }, + }, + }; + const docs = getExtensionDocs(infoWithPathCollision); + expect(docs.examples).toContain("### TestExtension Example (path1/duplicated_filename.html)"); + expect(docs.examples).toContain("### TestExtension Example (path2/duplicated_filename.html)"); + expect(docs.examples).not.toContain("### TestExtension Example (duplicated_filename.html)"); + }); + it("matches the rendered snapshot", () => { expect(getExtensionDocs(info)).toMatchSnapshot(); }); From c003f87c311a702312fd6049bf10dcadd5c25cba Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Thu, 16 Jul 2026 11:37:31 -0700 Subject: [PATCH 07/10] add test for two example files with same title - no renaming needed because paths are included and unique --- .../title-tag-collision-tests/file-a.html | 13 +++++++++++++ .../title-tag-collision-tests/file-b.html | 13 +++++++++++++ packages/autodoc/tests/parsers/extension.test.ts | 16 ++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 packages/autodoc/tests/fixtures/extension/title-tag-collision-tests/file-a.html create mode 100644 packages/autodoc/tests/fixtures/extension/title-tag-collision-tests/file-b.html diff --git a/packages/autodoc/tests/fixtures/extension/title-tag-collision-tests/file-a.html b/packages/autodoc/tests/fixtures/extension/title-tag-collision-tests/file-a.html new file mode 100644 index 0000000..9895d2c --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/title-tag-collision-tests/file-a.html @@ -0,0 +1,13 @@ + + + + My Example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/title-tag-collision-tests/file-b.html b/packages/autodoc/tests/fixtures/extension/title-tag-collision-tests/file-b.html new file mode 100644 index 0000000..b59965c --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/title-tag-collision-tests/file-b.html @@ -0,0 +1,13 @@ + + + + My Example + + + + + diff --git a/packages/autodoc/tests/parsers/extension.test.ts b/packages/autodoc/tests/parsers/extension.test.ts index c2f8612..0cad21b 100644 --- a/packages/autodoc/tests/parsers/extension.test.ts +++ b/packages/autodoc/tests/parsers/extension.test.ts @@ -124,6 +124,22 @@ describe('getExtensionInfoAndExamples', () => { warnSpy.mockRestore(); }); + it('keeps both examples when two files share the same 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); From ebe6d1e7649e491b0b99e1086f2d7f4fddc63d4d Mon Sep 17 00:00:00 2001 From: Becky Gilbert <beckyannegilbert@gmail.com> Date: Thu, 16 Jul 2026 13:30:27 -0700 Subject: [PATCH 08/10] update extension example parsing (inferCodeBlock) tests to use file path rather than title as key for accessing stored examples --- .../autodoc/tests/parsers/extension.test.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/autodoc/tests/parsers/extension.test.ts b/packages/autodoc/tests/parsers/extension.test.ts index 4655656..14c1988 100644 --- a/packages/autodoc/tests/parsers/extension.test.ts +++ b/packages/autodoc/tests/parsers/extension.test.ts @@ -148,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'); }); @@ -158,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); }); @@ -168,8 +168,8 @@ 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'); }); @@ -177,7 +177,7 @@ describe('inferCodeBlock (via getExtensionInfoAndExamples)', () => { 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')); }); @@ -186,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'); }); @@ -195,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"'); }); @@ -204,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(); }); }); From 280cc3c1e189bb268234a226c72b68b6e4a51dfc Mon Sep 17 00:00:00 2001 From: Becky Gilbert <beckyannegilbert@gmail.com> Date: Thu, 16 Jul 2026 13:31:54 -0700 Subject: [PATCH 09/10] collectExamples should still throw if it fails on a single example file, and only skip file if examplesPath is a directory --- packages/autodoc/src/parsers/utils.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/autodoc/src/parsers/utils.ts b/packages/autodoc/src/parsers/utils.ts index 9f3f650..f4b9b05 100644 --- a/packages/autodoc/src/parsers/utils.ts +++ b/packages/autodoc/src/parsers/utils.ts @@ -305,7 +305,11 @@ export function collectExamples( result[info.path] = info; } } catch (e) { - console.warn(`Warning: skipping ${file}: ${e instanceof Error ? e.message : e}`); + if (isDirectory) { + console.warn(`Warning: skipping ${file}: ${e instanceof Error ? e.message : e}`); + } else { + throw e; + } } } return result; From a84c0642d54ada785eda34d24eb2425d332e910b Mon Sep 17 00:00:00 2001 From: Becky Gilbert <beckyannegilbert@gmail.com> Date: Thu, 16 Jul 2026 13:41:28 -0700 Subject: [PATCH 10/10] collectExamples should throw if the only example file fails (regardless of directory structure) --- packages/autodoc/src/parsers/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/autodoc/src/parsers/utils.ts b/packages/autodoc/src/parsers/utils.ts index f4b9b05..e109450 100644 --- a/packages/autodoc/src/parsers/utils.ts +++ b/packages/autodoc/src/parsers/utils.ts @@ -305,7 +305,7 @@ export function collectExamples( result[info.path] = info; } } catch (e) { - if (isDirectory) { + if (htmlFiles.length > 1) { console.warn(`Warning: skipping ${file}: ${e instanceof Error ? e.message : e}`); } else { throw e;