Skip to content

Commit

Permalink
feat: Allow dependency injection for marked extensions (#569)
Browse files Browse the repository at this point in the history
* Allow dependency injection for marked extensions
* Adjust unit tests
  • Loading branch information
jfcere authored Feb 9, 2025
1 parent b38e89f commit 742b504
Show file tree
Hide file tree
Showing 6 changed files with 117 additions and 29 deletions.
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -763,26 +763,62 @@ MarkdownModule.forRoot({

### Marked extensions

You can provide [marked extensions](https://marked.js.org/using_advanced#extensions) using the `markedExtensions` property that accepts an array of extensions when configuring `MarkdownModule`.
When configuring the `MarkdownModule`, you can provide [marked extensions](https://marked.js.org/using_advanced#extensions) using the `markedExtensions` property that accepts an array of extension functions and/or providers to allow dependency injection using the `MARKED_EXTENSION` injection token when configuring `MarkdownModule`.

##### Using the `provideMarkdown` function

```typescript
import { gfmHeadingId } from 'marked-gfm-heading-id';

// using extension functions
providemarkdown({
markedExtensions: [gfmHeadingId()],
}),

// using `MARKED_EXTENSION` allows dependency injection
providemarkdown({
markedExtensions: [
{
provide: MARKED_EXTENSIONS,
useFactory: gfmHeadingId,
multi: true,
},
{
provide: MARKED_EXTENSIONS,
useFactory: myExtensionFactory,
deps: [SomeService],
multi: true,
},
],
}),
```

##### Using the `MarkdownModule` import

```typescript
import { gfmHeadingId } from 'marked-gfm-heading-id';

// using extension functions
MarkdownModule.forRoot({
markedExtensions: [gfmHeadingId()],
}),

// using `MARKED_EXTENSION` allows dependency injection
MarkdownModule.forRoot({
markedExtensions: [
{
provide: MARKED_EXTENSIONS,
useFactory: gfmHeadingId,
multi: true,
},
{
provide: MARKED_EXTENSIONS,
useFactory: myExtensionFactory,
deps: [SomeService],
multi: true,
},
],
}),
```

## Usage
Expand Down
10 changes: 8 additions & 2 deletions demo/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ApplicationConfig, SecurityContext } from '@angular/core';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { gfmHeadingId } from 'marked-gfm-heading-id';
import { CLIPBOARD_OPTIONS, MARKED_OPTIONS, MERMAID_OPTIONS, provideMarkdown } from 'ngx-markdown';
import { CLIPBOARD_OPTIONS, MARKED_EXTENSIONS, MARKED_OPTIONS, MERMAID_OPTIONS, provideMarkdown } from 'ngx-markdown';
import { appRoutes } from '@app/app-routes';
import { markedOptionsFactory } from '@app/marked-options-factory';
import { AnchorService } from '@shared/anchor/anchor.service';
Expand Down Expand Up @@ -31,7 +31,13 @@ export const appConfig: ApplicationConfig = {
useFactory: markedOptionsFactory,
deps: [AnchorService],
},
markedExtensions: [gfmHeadingId()],
markedExtensions: [
{
provide: MARKED_EXTENSIONS,
useFactory: gfmHeadingId,
multi: true,
},
],
mermaidOptions: {
provide: MERMAID_OPTIONS,
useValue: {
Expand Down
66 changes: 48 additions & 18 deletions lib/src/markdown.module.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ describe('MarkdownModule', () => {
],
});

const httpClient = TestBed.inject(HttpClient, null);
const httpClient = TestBed.inject(HttpClient, null, { optional: true });

expect(httpClient).toBeNull();
});
Expand All @@ -75,7 +75,7 @@ describe('MarkdownModule', () => {
],
});

const httpClient = TestBed.inject(HttpClient, null);
const httpClient = TestBed.inject(HttpClient, null, { optional: true });

expect(httpClient).toBeNull();
});
Expand All @@ -97,7 +97,7 @@ describe('MarkdownModule', () => {

const clipboardOptions = TestBed.inject(CLIPBOARD_OPTIONS);

expect(clipboardOptions).toBe(mockClipboardOptions);
expect(clipboardOptions).toEqual(mockClipboardOptions);
});

it('should not provide ClipboardOptions when MarkdownModuleConfig is provided without clipboardOptions', () => {
Expand All @@ -108,7 +108,7 @@ describe('MarkdownModule', () => {
],
});

const clipboardOptions = TestBed.inject(CLIPBOARD_OPTIONS, null);
const clipboardOptions = TestBed.inject(CLIPBOARD_OPTIONS, null, { optional: true });

expect(clipboardOptions).toBeNull();
});
Expand All @@ -130,7 +130,7 @@ describe('MarkdownModule', () => {

const markedOptions = TestBed.inject(MARKED_OPTIONS);

expect(markedOptions).toBe(mockMarkedOptions);
expect(markedOptions).toEqual(mockMarkedOptions);
});

it('should not provide MarkedOptions when MarkdownModuleConfig is provided without markedOptions', () => {
Expand All @@ -141,7 +141,7 @@ describe('MarkdownModule', () => {
],
});

const markedOptions = TestBed.inject(MARKED_OPTIONS, null);
const markedOptions = TestBed.inject(MARKED_OPTIONS, null, { optional: true });

expect(markedOptions).toBeNull();
});
Expand All @@ -154,27 +154,57 @@ describe('MarkdownModule', () => {
],
});

const markedOptions = TestBed.inject(MARKED_OPTIONS, null);
const markedOptions = TestBed.inject(MARKED_OPTIONS, null, { optional: true });

expect(markedOptions).toBeNull();
});

it('should provide MarkedExtensions when MarkdownModuleConfig is provided with markedExtensions', () => {

const mockExtensions = [{ name: 'mock-extension' } as MarkedExtension];
it('should provide MarkedExtensions when MarkdownModuleConfig is provided with markedExtension functions', () => {
const mockExtensions = [
{ name: 'mock-extension-one' } as MarkedExtension,
{ name: 'mock-extension-two' } as MarkedExtension,
];

TestBed.configureTestingModule({
imports: [
MarkdownModule.forRoot({ markedExtensions: mockExtensions }),
],
});

const markedExtensions = TestBed.inject(MARKED_EXTENSIONS);
const markedExtensions = TestBed.inject<MarkedExtension[]>(MARKED_EXTENSIONS);

expect(markedExtensions).toEqual(mockExtensions);
});

it('should provide MarkedExtensions when MarkdownModuleConfig is provided with markedExtension providers', () => {
const mockExtensionOne = { name: 'mock-extension-one' } as MarkedExtension;
const mockExtensionTwo = { name: 'mock-extension-two' } as MarkedExtension;

TestBed.configureTestingModule({
imports: [
MarkdownModule.forRoot({
markedExtensions: [
{
provide: MARKED_EXTENSIONS,
useValue: mockExtensionOne,
multi: true,
},
{
provide: MARKED_EXTENSIONS,
useFactory: () => mockExtensionTwo,
multi: true,
},
],
}),
],
});

const markedExtensions = TestBed.inject<MarkedExtension[]>(MARKED_EXTENSIONS);

expect(markedExtensions).toBe(mockExtensions);
expect(markedExtensions).toEqual([mockExtensionOne, mockExtensionTwo]);
});

it('should provide an empty array when MarkdownModuleConfig is provided without markedExtensions', () => {
it('should provide null when MarkdownModuleConfig is provided without markedExtensions', () => {

TestBed.configureTestingModule({
imports: [
Expand All @@ -187,22 +217,22 @@ describe('MarkdownModule', () => {
],
});

const markedExtensions = TestBed.inject(MARKED_EXTENSIONS);
const markedExtensions = TestBed.inject<MarkedExtension[]>(MARKED_EXTENSIONS, null, { optional: true });

expect(markedExtensions).toEqual([]);
expect(markedExtensions).toBeNull();
});

it('should provide an empty array when MarkdownModuleConfig is not provided', () => {
it('should provide null when MarkdownModuleConfig is not provided', () => {

TestBed.configureTestingModule({
imports: [
MarkdownModule.forRoot(),
],
});

const markedExtensions = TestBed.inject(MARKED_EXTENSIONS);
const markedExtensions = TestBed.inject<MarkedExtension[]>(MARKED_EXTENSIONS, null, { optional: true });

expect(markedExtensions).toEqual([]);
expect(markedExtensions).toBeNull();
});

it('should provide SecurityContext when MarkdownModuleConfig is provided with sanitize', () => {
Expand Down
22 changes: 21 additions & 1 deletion lib/src/markdown.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { CLIPBOARD_OPTIONS } from './clipboard-options';
import { LanguagePipe } from './language.pipe';
import { MarkdownComponent } from './markdown.component';
import { MarkdownPipe } from './markdown.pipe';
import { MARKED_EXTENSIONS } from './marked-extensions';
import { MARKED_OPTIONS } from './marked-options';
import { MERMAID_OPTIONS } from './mermaid-options';
import { provideMarkdown } from './provide-markdown';
Expand All @@ -25,6 +26,25 @@ interface TypedFactoryProvider<T extends InjectionToken<any>> {

type TypedProvider<T extends InjectionToken<any>> = TypedValueProvider<T> | TypedFactoryProvider<T>;

type MultiTypedProvider<T extends InjectionToken<any>> = TypedProvider<T> & { multi: true };

export function isTypedProvider<T extends InjectionToken<any>>(provider: any): provider is TypedProvider<T> {
return provider != null && provider.provide != null;
}

export function getMarkedExtensionProvider(markedExtensions: MarkdownModuleConfig['markedExtensions']): Provider[] | undefined {
if (!markedExtensions) {
return undefined;
}

return markedExtensions.reduce((acc, markedExtension) => {
const provider = isTypedProvider(markedExtension)
? { ...markedExtension, multi: true }
: { provide: MARKED_EXTENSIONS, useValue: markedExtension, multi: true };
return [...acc, provider];
}, [] as Provider[]);
}

// having a dependency on `HttpClientModule` within a library
// breaks all the interceptors from the app consuming the library
// here, we explicitely ask the user to pass a provider with
Expand All @@ -33,7 +53,7 @@ export interface MarkdownModuleConfig {
loader?: Provider;
clipboardOptions?: TypedProvider<typeof CLIPBOARD_OPTIONS>;
markedOptions?: TypedProvider<typeof MARKED_OPTIONS>;
markedExtensions?: MarkedExtension[];
markedExtensions?: (MarkedExtension | MultiTypedProvider<typeof MARKED_EXTENSIONS>)[];
mermaidOptions?: TypedProvider<typeof MERMAID_OPTIONS>;
sanitize?: SecurityContext;
}
Expand Down
2 changes: 1 addition & 1 deletion lib/src/marked-extensions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { InjectionToken } from '@angular/core';
import { MarkedExtension } from 'marked';

export const MARKED_EXTENSIONS = new InjectionToken<MarkedExtension[]>('MARKED_EXTENSIONS');
export const MARKED_EXTENSIONS = new InjectionToken<MarkedExtension>('MARKED_EXTENSIONS');
8 changes: 2 additions & 6 deletions lib/src/provide-markdown.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Provider, SecurityContext } from '@angular/core';
import { MarkdownModuleConfig } from './markdown.module';
import { getMarkedExtensionProvider, MarkdownModuleConfig } from './markdown.module';
import { MarkdownService, SECURITY_CONTEXT } from './markdown.service';
import { MARKED_EXTENSIONS } from './marked-extensions';

export function provideMarkdown(markdownModuleConfig?: MarkdownModuleConfig): Provider[] {
return [
Expand All @@ -10,10 +9,7 @@ export function provideMarkdown(markdownModuleConfig?: MarkdownModuleConfig): Pr
markdownModuleConfig?.clipboardOptions ?? [],
markdownModuleConfig?.markedOptions ?? [],
markdownModuleConfig?.mermaidOptions ?? [],
{
provide: MARKED_EXTENSIONS,
useValue: markdownModuleConfig?.markedExtensions ?? [],
},
getMarkedExtensionProvider(markdownModuleConfig?.markedExtensions) ?? [],
{
provide: SECURITY_CONTEXT,
useValue: markdownModuleConfig?.sanitize ?? SecurityContext.HTML,
Expand Down

0 comments on commit 742b504

Please sign in to comment.