From 6037df5f766333555d5ed54b455535a10726055e Mon Sep 17 00:00:00 2001 From: David Sanders Date: Tue, 27 Oct 2026 14:06:12 -0700 Subject: [PATCH] fix: keep content before the next heading when slicing tokens findContentAfterList and findContentAfterHeadingClose computed the end index relative to a slice but used it as an absolute index, dropping the token before the next heading (often a code fence) or emptying the description entirely for structures and events. Assisted-by: Claude Fable 5.1 --- src/markdown-helpers.ts | 6 ++-- tests/DocsParser.spec.ts | 65 ++++++++++++++++++++++++++++++++++ tests/markdown-helpers.spec.ts | 56 +++++++++++++++++++++++++++-- 3 files changed, 123 insertions(+), 4 deletions(-) diff --git a/src/markdown-helpers.ts b/src/markdown-helpers.ts index 1c70ab8..5dc64ff 100644 --- a/src/markdown-helpers.ts +++ b/src/markdown-helpers.ts @@ -91,16 +91,18 @@ export const findContentAfterList = (tokens: Token[], returnAllOnNoList = false) if (!returnAllOnNoList) return []; start = tokens.findIndex((t) => t.type === 'heading_close'); } + // `end` is relative to `start`, so offset it back into `tokens` coordinates const end = tokens.slice(start).findIndex((t) => t.type === 'heading_open'); if (end === -1) return tokens.slice(start + 1); - return tokens.slice(start + 1, end); + return tokens.slice(start + 1, start + end); }; export const findContentAfterHeadingClose = (tokens: Token[]) => { const start = tokens.findIndex((t) => t.type === 'heading_close'); + // `end` is relative to `start`, so offset it back into `tokens` coordinates const end = tokens.slice(start).findIndex((t) => t.type === 'heading_open'); if (end === -1) return tokens.slice(start + 1); - return tokens.slice(start + 1, end); + return tokens.slice(start + 1, start + end); }; export type HeadingContent = { diff --git a/tests/DocsParser.spec.ts b/tests/DocsParser.spec.ts index 8bb3ad9..6e4d31d 100644 --- a/tests/DocsParser.spec.ts +++ b/tests/DocsParser.spec.ts @@ -79,6 +79,43 @@ A \`string\` property that indicates the current application's name. expect(appModule.properties[0].name).toBe('name'); }); + it('should include a code fence at the end of a module description', async () => { + const moduleContent = `# app + +> Control your application's event lifecycle. + +Process: [Main](../glossary.md#main-process) + +The following example shows how to quit the application when the last window +is closed: + +\`\`\`js +const { app } = require('electron') +app.on('window-all-closed', () => { + app.quit() +}) +\`\`\` + +## Methods + +### \`app.quit()\` + +Try to close all windows. +`; + + const modulePath = path.join(tempDir, 'docs', 'api', 'app.md'); + await fs.promises.writeFile(modulePath, moduleContent); + + const parser = new DocsParser(tempDir, '1.0.0', [modulePath], [], 'single'); + const result = await parser.parse(); + + const appModule = result.find((m) => m.name === 'app'); + assert(appModule?.type === 'Module', 'Parsed module should be of type Module'); + expect(appModule.description).toContain('is closed:'); + expect(appModule.description).toContain("const { app } = require('electron')"); + expect(appModule.description).not.toContain('Try to close all windows'); + }); + it('should parse a class documentation', async () => { const classContent = `# BrowserWindow @@ -354,6 +391,34 @@ Additional description after the property list. expect(rectangleStructure.properties[3].name).toBe('height'); }); + it('should keep the description when a heading follows the property list', async () => { + const structureContent = `# PrinterInfo Object + +* \`name\` string - The name of the printer as understood by the OS. +* \`status\` number - The current status of the printer. + +The number represented by \`status\` means different things on different platforms. + +## Example + +Below is an example of some of the additional options that may be set. +`; + + const structurePath = path.join(tempDir, 'docs', 'api', 'structures', 'printer-info.md'); + await fs.promises.writeFile(structurePath, structureContent); + + const parser = new DocsParser(tempDir, '1.0.0', [], [structurePath], 'single'); + const result = await parser.parse(); + + const printerInfo = result.find((s) => s.name === 'PrinterInfo'); + assert(printerInfo?.type === 'Structure', 'Parsed structure should be of type Structure'); + + expect(printerInfo.properties).toHaveLength(2); + expect(printerInfo.description).toBe( + 'The number represented by `status` means different things on different platforms.', + ); + }); + it('should parse a structure with optional properties', async () => { const structureContent = `# Options Object diff --git a/tests/markdown-helpers.spec.ts b/tests/markdown-helpers.spec.ts index c8097f3..a0c01fb 100644 --- a/tests/markdown-helpers.spec.ts +++ b/tests/markdown-helpers.spec.ts @@ -862,6 +862,36 @@ After nested list. const joined = safelyJoinTokens(content); expect(joined).toContain('After nested list'); }); + + it('should return all content between the list and the next heading', () => { + const md = `# Heading + +* Item 1 +* Item 2 + +First paragraph after the list. + +Second paragraph after the list. + +\`\`\`js +const code = 'fence' +\`\`\` + +## Next Heading + +Content that should not be included. +`; + const tokens = getTokens(md); + const content = findContentAfterList(tokens); + expect(content[0].type).toBe('paragraph_open'); + expect(content[content.length - 1].type).toBe('fence'); + const joined = safelyJoinTokens(content, { parseCodeFences: true }); + expect(joined).toContain('First paragraph after the list.'); + expect(joined).toContain('Second paragraph after the list.'); + expect(joined).toContain("const code = 'fence'"); + expect(joined).not.toContain('Next Heading'); + expect(joined).not.toContain('should not be included'); + }); }); describe('findContentAfterHeadingClose', () => { @@ -885,8 +915,30 @@ Content for heading one. Content for heading two.`; const tokens = getTokens(md); const content = findContentAfterHeadingClose(tokens); - // The function returns tokens, check that we got some - expect(content.length).toBeGreaterThan(0); + expect(content[0].type).toBe('paragraph_open'); + expect(content[content.length - 1].type).toBe('paragraph_close'); + const joined = safelyJoinTokens(content); + expect(joined).toBe('Content for heading one.'); + }); + + it('should not drop a trailing code fence before the next heading', () => { + const md = `# Heading One + +Content for heading one. + +\`\`\`js +const code = 'fence' +\`\`\` + +## Heading Two + +Content for heading two.`; + const tokens = getTokens(md); + const content = findContentAfterHeadingClose(tokens); + expect(content[content.length - 1].type).toBe('fence'); + const joined = safelyJoinTokens(content, { parseCodeFences: true }); + expect(joined).toContain("const code = 'fence'"); + expect(joined).not.toContain('Content for heading two.'); }); });