diff --git a/apps/docs-app/docs/features/routing/content.md b/apps/docs-app/docs/features/routing/content.md index 52a7b097b..489a552e9 100644 --- a/apps/docs-app/docs/features/routing/content.md +++ b/apps/docs-app/docs/features/routing/content.md @@ -182,3 +182,15 @@ export default class ProjectComponent { }); } ``` + +## Loading Custom Content + +By default, Analog uses the route params to build the filename for retrieving a content file from the `src/content` folder. Analog also supports using a custom filename for retrieving content from the `src/content` folder. This can be useful if, for instance, you have a custom markdown file that you want to load on a page. + +The `injectContent()` function can be used by passing an object that contains the `customFilename` property. + +```ts +readonly post$ = injectContent({ + customFilename: 'path/to/custom/file', +}); +``` diff --git a/packages/content/src/lib/content.spec.ts b/packages/content/src/lib/content.spec.ts index 12604c80f..e109eff6b 100644 --- a/packages/content/src/lib/content.spec.ts +++ b/packages/content/src/lib/content.spec.ts @@ -1,4 +1,9 @@ -import { fakeAsync, flushMicrotasks, TestBed } from '@angular/core/testing'; +import { + fakeAsync, + flushMicrotasks, + flush, + TestBed, +} from '@angular/core/testing'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { expect } from 'vitest'; import { Observable, of } from 'rxjs'; @@ -22,6 +27,7 @@ describe('injectContent', () => { expect(c.filename).toEqual('/src/content/test.md'); }); flushMicrotasks(); + flush(); })); it("should return ContentFile object with empty filename, empty attributes, and the custom fallback 'Custom Fallback' as content when no match between slug and files and custom fallback 'Custom Fallback' provided", fakeAsync(() => { @@ -38,6 +44,7 @@ describe('injectContent', () => { expect(c.filename).toEqual('/src/content/test.md'); }); flushMicrotasks(); + flush(); })); it('should return ContentFile object with correct filename, correct attributes, and the correct content of the file when match between slug and files', fakeAsync(() => { @@ -65,6 +72,7 @@ Test Content`), expect(c.slug).toEqual('test'); }); flushMicrotasks(); + flush(); })); it('should return ContentFile object with correct filename, correct attributes, and the correct content of the file when match between custom param and files', fakeAsync(() => { @@ -94,6 +102,7 @@ Test Content`), expect(c.slug).toEqual('custom-slug-test'); }); flushMicrotasks(); + flush(); })); it('should return ContentFile object when a custom param with prefix is provided', fakeAsync(() => { @@ -125,11 +134,45 @@ Test Content`), expect(c.slug).toEqual('custom-prefix-slug-test'); }); flushMicrotasks(); + flush(); + })); + + it('should return ContentFile object when a custom filename is provided', fakeAsync(() => { + const customParam = { customFilename: 'custom-filename-test' }; + const routeParams = {}; + const contentFiles = { + '/src/content/dont-match.md': () => + Promise.resolve(`--- +slug: 'dont-match' +--- +Dont Match'`), + '/src/content/custom-filename-test.md': () => + Promise.resolve(`--- +slug: 'custom-filename-test-slug' +--- +Test Content`), + }; + const { injectContent } = setup({ + customParam, + routeParams, + contentFiles, + }); + injectContent().subscribe((c) => { + expect(c.content).toMatch('Test Content'); + expect(c.attributes).toEqual({ slug: 'custom-filename-test-slug' }); + expect(c.filename).toEqual('/src/content/custom-filename-test.md'); + expect(c.slug).toEqual('custom-filename-test'); + }); + flushMicrotasks(); + flush(); })); function setup( args: Partial<{ - customParam: string | { subdirectory: string; param: string }; + customParam: + | string + | { subdirectory: string; param: string } + | { customFilename: string }; customFallback: string; routeParams: { [key: string]: any }; contentFiles: Record Promise>; diff --git a/packages/content/src/lib/content.ts b/packages/content/src/lib/content.ts index 9b3dca359..a8b4ba885 100644 --- a/packages/content/src/lib/content.ts +++ b/packages/content/src/lib/content.ts @@ -10,6 +10,52 @@ import { CONTENT_FILES_TOKEN } from './content-files-token'; import { parseRawContentFile } from './parse-raw-content-file'; import { waitFor } from './utils/zone-wait-for'; +function getContentFile< + Attributes extends Record = Record +>( + contentFiles: Record Promise>, + prefix: string, + slug: string, + fallback: string +): Observable>> { + const filePath = `/src/content/${prefix}${slug}.md`; + const contentFile = contentFiles[filePath]; + if (!contentFile) { + return of({ + filename: filePath, + attributes: {}, + slug: '', + content: fallback, + }); + } + + return new Observable((observer) => { + const contentResolver = contentFile(); + + if (import.meta.env.SSR === true) { + waitFor(contentResolver).then((content) => { + observer.next(content); + }); + } else { + contentResolver.then((content) => { + observer.next(content); + }); + } + }).pipe( + map((rawContentFile) => { + const { content, attributes } = + parseRawContentFile(rawContentFile); + + return { + filename: filePath, + slug, + attributes, + content, + }; + }) + ); +} + /** * Retrieves the static content using the provided param and/or prefix. * @@ -24,52 +70,44 @@ export function injectContent< | { param: string; subdirectory: string; + } + | { + customFilename: string; } = 'slug', fallback = 'No Content Found' ): Observable>> { - const route = inject(ActivatedRoute); const contentFiles = inject(CONTENT_FILES_TOKEN); - const prefix = typeof param === 'string' ? '' : `${param.subdirectory}/`; - - const paramKey = typeof param === 'string' ? param : param.param; - return route.paramMap.pipe( - map((params) => params.get(paramKey)), - switchMap((slug) => { - const filename = `/src/content/${prefix}${slug}.md`; - const contentFile = contentFiles[filename]; - - if (!contentFile) { - return of({ - attributes: {}, - filename: filename, - slug: slug || '', - content: fallback, - }); - } - - return new Promise((resolve) => { - const contentResolver = contentFile(); - if (import.meta.env.SSR === true) { - waitFor(contentResolver).then((content) => { - resolve(content); - }); + if (typeof param === 'string' || 'param' in param) { + const prefix = typeof param === 'string' ? '' : `${param.subdirectory}/`; + const route = inject(ActivatedRoute); + const paramKey = typeof param === 'string' ? param : param.param; + return route.paramMap.pipe( + map((params) => params.get(paramKey)), + switchMap((slug) => { + if (slug) { + return getContentFile( + contentFiles, + prefix, + slug, + fallback + ); } else { - contentResolver.then((content) => { - resolve(content); + return of({ + filename: '', + slug: '', + attributes: {}, + content: fallback, }); } - }).then((rawContentFile) => { - const { content, attributes } = - parseRawContentFile(rawContentFile); - - return { - filename, - slug: slug || '', - attributes, - content, - }; - }); - }) - ); + }) + ); + } else { + return getContentFile( + contentFiles, + '', + param.customFilename, + fallback + ); + } }