diff --git a/src/__tests__/slideshow.test.ts b/src/__tests__/slideshow.test.ts index 51c1f3b..5aae4ca 100644 --- a/src/__tests__/slideshow.test.ts +++ b/src/__tests__/slideshow.test.ts @@ -21,6 +21,40 @@ function makeDom(): Dom { } as unknown as Dom; } +/** Build a Dom whose XHR automatically resolves each URL to a given content map. */ +function makeDomWithUrls(urlMap: Record): Dom { + const XHRMock = class { + url = ''; + readyState = 0; + status = 0; + responseText = ''; + onload?: () => void; + onerror?: () => void; + open(_method: string, url: string) { this.url = url; } + send() { + // Resolve asynchronously so Promise.all works correctly + Promise.resolve().then(() => { + if (this.url in urlMap) { + this.readyState = 4; + this.status = 200; + this.responseText = urlMap[this.url]; + this.onload?.(); + } else { + this.onerror?.(); + } + }); + } + }; + return { + getHTMLElement: () => document.documentElement, + getBodyElement: () => document.body as HTMLBodyElement, + getElementById: () => null, + getLocationHash: () => '', + setLocationHash: () => {}, + XMLHttpRequest: XHRMock as unknown as typeof XMLHttpRequest, + } as unknown as Dom; +} + describe('Slideshow', () => { let events: EventEmitter; let slideshow: Slideshow; @@ -249,4 +283,74 @@ describe('Slideshow', () => { expect(slideshow.getHighlightSpans()).toBe(false); }); }); + + describe('sourceUrls (issue #7)', () => { + it('loads and concatenates multiple URLs in order', async () => { + const urlDom = makeDomWithUrls({ + 'a.md': '# Slide A', + 'b.md': '# Slide B', + 'c.md': '# Slide C', + }); + let ss!: Slideshow; + await new Promise((resolve) => { + ss = new Slideshow(new EventEmitter(), urlDom, { sourceUrls: ['a.md', 'b.md', 'c.md'] }, () => resolve()); + }); + expect(ss.getSlideCount()).toBe(3); + }); + + it('creates the correct number of slides from multiple URLs', async () => { + const urlDom = makeDomWithUrls({ + 'intro.md': '# Intro', + 'chapter.md': '# Chapter 1\n---\n# Chapter 2', + }); + let ss!: Slideshow; + await new Promise((resolve) => { + ss = new Slideshow(events, urlDom, { sourceUrls: ['intro.md', 'chapter.md'] }, () => resolve()); + }); + expect(ss.getSlideCount()).toBe(3); + }); + + it('preserves content from each URL', async () => { + const urlDom = makeDomWithUrls({ + 'part1.md': 'First', + 'part2.md': 'Second', + }); + let ss!: Slideshow; + await new Promise((resolve) => { + ss = new Slideshow(events, urlDom, { sourceUrls: ['part1.md', 'part2.md'] }, () => resolve()); + }); + const content = ss.getSlides().map((s) => s.content.join('')); + expect(content.some((c) => c.includes('First'))).toBe(true); + expect(content.some((c) => c.includes('Second'))).toBe(true); + }); + + it('loadFromUrls works as a public method', async () => { + const urlDom = makeDomWithUrls({ + 'x.md': '# X', + 'y.md': '# Y', + }); + let ss!: Slideshow; + await new Promise((resolve) => { + ss = new Slideshow(events, urlDom, {}); + ss.loadFromUrls(['x.md', 'y.md'], () => resolve()); + }); + expect(ss.getSlideCount()).toBe(2); + }); + + it('sourceUrls takes precedence over sourceUrl when both provided', async () => { + const urlDom = makeDomWithUrls({ + 'single.md': '# Single', + 'multi1.md': '# Multi 1', + 'multi2.md': '# Multi 2', + }); + let ss!: Slideshow; + await new Promise((resolve) => { + ss = new Slideshow(events, urlDom, { + sourceUrl: 'single.md', + sourceUrls: ['multi1.md', 'multi2.md'], + }, () => resolve()); + }); + expect(ss.getSlideCount()).toBe(2); + }); + }); }); diff --git a/src/mdeck/models/slideshow.ts b/src/mdeck/models/slideshow.ts index bcac195..0784d25 100644 --- a/src/mdeck/models/slideshow.ts +++ b/src/mdeck/models/slideshow.ts @@ -9,6 +9,7 @@ import type { Dom } from '../dom.js'; export interface SlideshowOptions { source?: string; sourceUrl?: string; + sourceUrls?: string[]; container?: HTMLElement; ratio?: string; highlightStyle?: string; @@ -71,7 +72,9 @@ export class Slideshow { applyEvents(this, _events); applyNavigation(this, _events, _options); - if (_options.sourceUrl) { + if (_options.sourceUrls?.length) { + this._loadFromUrls(_options.sourceUrls, callback); + } else if (_options.sourceUrl) { this._loadFromUrl(_options.sourceUrl, callback); } else { this._loadFromString(_options.source || ''); @@ -81,6 +84,7 @@ export class Slideshow { loadFromString(source: string): void { this._loadFromString(source); } loadFromUrl(url: string, callback?: (s: Slideshow) => void): void { this._loadFromUrl(url, callback); } + loadFromUrls(urls: string[], callback?: (s: Slideshow) => void): void { this._loadFromUrls(urls, callback); } update(): void { this._events.emit('resize'); } getLinks() { return this._links; } @@ -139,6 +143,30 @@ export class Slideshow { xhr.onerror = () => { throw new Error(xhr.statusText); }; xhr.send(null); } + + private _loadFromUrls(urls: string[], callback?: (s: Slideshow) => void): void { + const fetchOne = (url: string): Promise => new Promise((resolve, reject) => { + const xhr = new (this._dom.XMLHttpRequest)(); + xhr.open('GET', url, true); + xhr.onload = () => { + if (xhr.readyState === 4) { + if (xhr.status === 200) { + resolve(xhr.responseText.replace(/\r\n/g, '\n')); + } else { + reject(new Error(xhr.statusText)); + } + } + }; + xhr.onerror = () => reject(new Error(xhr.statusText)); + xhr.send(null); + }); + + Promise.all(urls.map(fetchOne)).then((parts) => { + this._options.source = parts.join('\n---\n'); + this._loadFromString(this._options.source); + callback?.(this); + }).catch((err) => { throw err; }); + } } function createSlides(source: string, options: SlideshowOptions): { slides: Slide[]; byName: Record; byNumber: Record } {