UNPKG

@analogjs/content

Version:

Content Rendering for Analog

535 lines (516 loc) 22.5 kB
import * as i0 from '@angular/core'; import { inject, HostListener, Directive, InjectionToken, ɵPendingTasksInternal as _PendingTasksInternal, Injectable, TransferState, makeStateKey, Input, ViewEncapsulation, Component, NgZone, PLATFORM_ID, ViewContainerRef, ViewChild } from '@angular/core'; import { DOCUMENT, Location, AsyncPipe, isPlatformBrowser } from '@angular/common'; import { Router, ActivatedRoute } from '@angular/router'; import { isObservable, firstValueFrom, of, Observable, from } from 'rxjs'; import { map, switchMap, tap, filter, mergeMap, catchError } from 'rxjs/operators'; import fm from 'front-matter'; import { getHeadingList, gfmHeadingId } from 'marked-gfm-heading-id'; import { marked } from 'marked'; import { mangle } from 'marked-mangle'; import { DomSanitizer } from '@angular/platform-browser'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; class AnchorNavigationDirective { constructor() { this.document = inject(DOCUMENT); this.location = inject(Location); this.router = inject(Router); } handleNavigation(element) { if (element instanceof HTMLAnchorElement && isInternalUrl(element, this.document) && hasTargetSelf(element) && !hasDownloadAttribute(element)) { const { pathname, search, hash } = element; const url = this.location.normalize(`${pathname}${search}${hash}`); this.router.navigateByUrl(url); return false; } return true; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: AnchorNavigationDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.1.4", type: AnchorNavigationDirective, isStandalone: true, selector: "[analogAnchorNavigation]", host: { listeners: { "click": "handleNavigation($event.target)" } }, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: AnchorNavigationDirective, decorators: [{ type: Directive, args: [{ selector: '[analogAnchorNavigation]', standalone: true, }] }], propDecorators: { handleNavigation: [{ type: HostListener, args: ['click', ['$event.target']] }] } }); function hasDownloadAttribute(anchorElement) { return anchorElement.getAttribute('download') !== null; } function hasTargetSelf(anchorElement) { return !anchorElement.target || anchorElement.target === '_self'; } function isInternalUrl(anchorElement, document) { return (anchorElement.host === document.location.host && anchorElement.protocol === document.location.protocol); } /** * Returns the list of content files by filename with ?analog-content-list=true. * We use the query param to transform the return into an array of * just front matter attributes. * * @returns */ const getContentFilesList = () => { let ANALOG_CONTENT_FILE_LIST = {}; return ANALOG_CONTENT_FILE_LIST; }; /** * Returns the lazy loaded content files for lookups. * * @returns */ const getContentFiles = () => { let ANALOG_CONTENT_ROUTE_FILES = {}; return ANALOG_CONTENT_ROUTE_FILES; }; const getAgxFiles = () => { let ANALOG_AGX_FILES = {}; return ANALOG_AGX_FILES; }; function getSlug(filename) { const parts = filename.match(/^(\\|\/)(.+(\\|\/))*(.+)\.(.+)$/); return parts?.length ? parts[4] : ''; } const CONTENT_FILES_LIST_TOKEN = new InjectionToken('@analogjs/content Content Files List', { providedIn: 'root', factory() { const contentFiles = getContentFilesList(); return Object.keys(contentFiles).map((filename) => { const attributes = contentFiles[filename]; const slug = attributes['slug']; return { filename, attributes, slug: slug ? encodeURI(slug) : encodeURI(getSlug(filename)), }; }); }, }); const CONTENT_FILES_TOKEN = new InjectionToken('@analogjs/content Content Files', { providedIn: 'root', factory() { const contentFiles = getContentFiles(); const agxFiles = getAgxFiles(); const allFiles = { ...contentFiles, ...agxFiles }; const contentFilesList = inject(CONTENT_FILES_LIST_TOKEN); const lookup = {}; contentFilesList.forEach((item) => { const fileParts = item.filename.split('/'); const filePath = fileParts.slice(0, fileParts.length - 1).join('/'); const fileNameParts = fileParts[fileParts.length - 1].split('.'); lookup[item.filename] = `${filePath}/${item.slug}.${fileNameParts[fileNameParts.length - 1]}`; }); const objectUsingSlugAttribute = {}; Object.entries(allFiles).forEach((entry) => { const filename = entry[0]; const value = entry[1]; const newFilename = lookup[filename]; if (newFilename !== undefined) { const objectFilename = newFilename.replace(/^\/(.*?)\/content/, '/src/content'); objectUsingSlugAttribute[objectFilename] = value; } }); return objectUsingSlugAttribute; }, }); function parseRawContentFile(rawContentFile) { const { body, attributes } = fm(rawContentFile); return { content: body, attributes }; } async function waitFor(prom) { if (isObservable(prom)) { prom = firstValueFrom(prom); } if (typeof Zone === 'undefined') { return prom; } const macroTask = Zone.current.scheduleMacroTask(`AnalogContentResolve-${Math.random()}`, () => { }, {}, () => { }); return prom.then((p) => { macroTask.invoke(); return p; }); } class RenderTaskService { #pendingTasks = inject(_PendingTasksInternal); addRenderTask() { return this.#pendingTasks.add(); } clearRenderTask(clear) { if (typeof clear === 'function') { clear(); } else if (typeof this.#pendingTasks.remove === 'function') { this.#pendingTasks.remove(clear); } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: RenderTaskService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: RenderTaskService }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: RenderTaskService, decorators: [{ type: Injectable }] }); /// <reference types="vite/client" /> function getContentFile(contentFiles, prefix, slug, fallback, renderTaskService) { const filePath = `/src/content/${prefix}${slug}`; const contentFile = contentFiles[`${filePath}.md`] ?? contentFiles[`${filePath}.agx`]; if (!contentFile) { return of({ filename: filePath, attributes: {}, slug: '', content: fallback, }); } const contentTask = renderTaskService.addRenderTask(); return new Observable((observer) => { const contentResolver = contentFile(); if (import.meta.env.SSR === true) { waitFor(contentResolver).then((content) => { observer.next(content); observer.complete(); setTimeout(() => renderTaskService.clearRenderTask(contentTask), 10); }); } else { contentResolver.then((content) => { observer.next(content); observer.complete(); }); } }).pipe(map((contentFile) => { if (typeof contentFile === 'string') { const { content, attributes } = parseRawContentFile(contentFile); return { filename: filePath, slug, attributes, content, }; } return { filename: filePath, slug, attributes: contentFile.metadata, content: contentFile.default, }; })); } /** * Retrieves the static content using the provided param and/or prefix. * * @param param route parameter (default: 'slug') * @param fallback fallback text if content file is not found (default: 'No Content Found') */ function injectContent(param = 'slug', fallback = 'No Content Found') { const contentFiles = inject(CONTENT_FILES_TOKEN); const renderTaskService = inject(RenderTaskService); const task = renderTaskService.addRenderTask(); 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, renderTaskService); } return of({ filename: '', slug: '', attributes: {}, content: fallback, }); }), tap(() => renderTaskService.clearRenderTask(task))); } else { return getContentFile(contentFiles, '', param.customFilename, fallback, renderTaskService).pipe(tap(() => renderTaskService.clearRenderTask(task))); } } /// <reference types="vite/client" /> class ContentRenderer { async render(content) { return content; } getContentHeadings() { return []; } // eslint-disable-next-line enhance() { } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: ContentRenderer, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: ContentRenderer }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: ContentRenderer, decorators: [{ type: Injectable }] }); class NoopContentRenderer { constructor() { this.transferState = inject(TransferState); this.contentId = 0; } /** * Generates a hash from the content string * to be used with the transfer state */ generateHash(str) { let hash = 0; for (let i = 0, len = str.length; i < len; i++) { let chr = str.charCodeAt(i); hash = (hash << 5) - hash + chr; hash |= 0; // Convert to 32bit integer } return hash; } async render(content) { this.contentId = this.generateHash(content); return content; } enhance() { } getContentHeadings() { const key = makeStateKey(`content-headings-${this.contentId}`); if (import.meta.env.SSR === true) { const headings = getHeadingList(); this.transferState.set(key, headings); return headings; } return this.transferState.get(key, []); } } function injectContentFiles(filterFn) { const renderTaskService = inject(RenderTaskService); const task = renderTaskService.addRenderTask(); const allContentFiles = inject(CONTENT_FILES_LIST_TOKEN); renderTaskService.clearRenderTask(task); if (filterFn) { const filteredContentFiles = allContentFiles.filter(filterFn); return filteredContentFiles; } return allContentFiles; } class MarkedContentHighlighter { static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: MarkedContentHighlighter, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: MarkedContentHighlighter }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: MarkedContentHighlighter, decorators: [{ type: Injectable }] }); function withHighlighter(provider) { return { provide: MarkedContentHighlighter, ...provider }; } /** * Credit goes to Scully for original implementation * https://github.com/scullyio/scully/blob/main/libs/scully/src/lib/fileHanderPlugins/markdown.ts */ class MarkedSetupService { constructor() { this.highlighter = inject(MarkedContentHighlighter, { optional: true, }); const renderer = new marked.Renderer(); renderer.code = ({ text, lang }) => { // Let's do a language based detection like on GitHub // So we can still have non-interpreted mermaid code if (lang === 'mermaid') { return '<pre class="mermaid">' + text + '</pre>'; } if (!lang) { return '<pre><code>' + text + '</code></pre>'; } if (this.highlighter?.augmentCodeBlock) { return this.highlighter?.augmentCodeBlock(text, lang); } return `<pre class="language-${lang}"><code class="language-${lang}">${text}</code></pre>`; }; const extensions = [gfmHeadingId(), mangle()]; if (this.highlighter) { extensions.push(this.highlighter.getHighlightExtension()); } marked.use(...extensions, { renderer, pedantic: false, gfm: true, breaks: false, }); this.marked = marked; } getMarkedInstance() { return this.marked; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: MarkedSetupService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: MarkedSetupService }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: MarkedSetupService, decorators: [{ type: Injectable }], ctorParameters: () => [] }); class MarkdownContentRendererService { #marked = inject(MarkedSetupService, { self: true }); async render(content) { return this.#marked.getMarkedInstance().parse(content); } /** * The method is meant to be called after `render()` */ getContentHeadings() { return getHeadingList(); } // eslint-disable-next-line enhance() { } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: MarkdownContentRendererService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: MarkdownContentRendererService }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: MarkdownContentRendererService, decorators: [{ type: Injectable }] }); const CONTENT_RENDERER_PROVIDERS = [ { provide: ContentRenderer, useClass: NoopContentRenderer, }, ]; function withMarkdownRenderer(options) { return [ CONTENT_RENDERER_PROVIDERS, options?.loadMermaid ? [ { provide: MERMAID_IMPORT_TOKEN, useFactory: options.loadMermaid, }, ] : [], ]; } function provideContent(...features) { return [ { provide: RenderTaskService, useClass: RenderTaskService }, ...features, ]; } const MERMAID_IMPORT_TOKEN = new InjectionToken('mermaid_import'); class AnalogMarkdownRouteComponent { constructor() { this.sanitizer = inject(DomSanitizer); this.route = inject(ActivatedRoute); this.contentRenderer = inject(ContentRenderer); this.content = this.sanitizer.bypassSecurityTrustHtml(this.route.snapshot.data['renderedAnalogContent']); this.classes = 'analog-markdown-route'; } ngAfterViewChecked() { this.contentRenderer.enhance(); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: AnalogMarkdownRouteComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.1.4", type: AnalogMarkdownRouteComponent, isStandalone: true, selector: "analog-markdown-route", inputs: { classes: "classes" }, hostDirectives: [{ directive: AnchorNavigationDirective }], ngImport: i0, template: `<div [innerHTML]="content" [class]="classes"></div>`, isInline: true, encapsulation: i0.ViewEncapsulation.None, preserveWhitespaces: true }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: AnalogMarkdownRouteComponent, decorators: [{ type: Component, args: [{ selector: 'analog-markdown-route', standalone: true, imports: [AsyncPipe], hostDirectives: [AnchorNavigationDirective], preserveWhitespaces: true, encapsulation: ViewEncapsulation.None, template: `<div [innerHTML]="content" [class]="classes"></div>`, }] }], propDecorators: { classes: [{ type: Input }] } }); class AnalogMarkdownComponent { constructor() { this.sanitizer = inject(DomSanitizer); this.route = inject(ActivatedRoute); this.zone = inject(NgZone); this.platformId = inject(PLATFORM_ID); this.mermaidImport = inject(MERMAID_IMPORT_TOKEN, { optional: true, }); this.content$ = this.getContentSource(); this.classes = 'analog-markdown'; this.contentRenderer = inject(ContentRenderer); if (isPlatformBrowser(this.platformId) && this.mermaidImport) { // Mermaid can only be loaded on client side this.loadMermaid(this.mermaidImport); } } ngOnInit() { this.updateContent(); } ngOnChanges() { this.updateContent(); } updateContent() { if (this.content && typeof this.content !== 'string') { this.container.clear(); const componentRef = this.container.createComponent(this.content); componentRef.changeDetectorRef.detectChanges(); } else { this.content$ = this.getContentSource(); } } getContentSource() { return this.route.data.pipe(map((data) => this.content ?? data['_analogContent']), filter((content) => typeof content === 'string'), mergeMap((contentString) => this.renderContent(contentString)), map((content) => this.sanitizer.bypassSecurityTrustHtml(content)), catchError((e) => of(`There was an error ${e}`))); } async renderContent(content) { return this.contentRenderer.render(content); } ngAfterViewChecked() { this.contentRenderer.enhance(); this.zone.runOutsideAngular(() => this.mermaid?.default.run()); } loadMermaid(mermaidImport) { this.zone.runOutsideAngular(() => // Wrap into an observable to avoid redundant initialization once // the markdown component is destroyed before the promise is resolved. from(mermaidImport) .pipe(takeUntilDestroyed()) .subscribe((mermaid) => { this.mermaid = mermaid; this.mermaid.default.initialize({ startOnLoad: false }); // Explicitly running mermaid as ngAfterViewChecked // has probably already been called this.mermaid?.default.run(); })); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: AnalogMarkdownComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.1.4", type: AnalogMarkdownComponent, isStandalone: true, selector: "analog-markdown", inputs: { content: "content", classes: "classes" }, viewQueries: [{ propertyName: "container", first: true, predicate: ["container"], descendants: true, read: ViewContainerRef, static: true }], usesOnChanges: true, hostDirectives: [{ directive: AnchorNavigationDirective }], ngImport: i0, template: `<div #container [innerHTML]="content$ | async" [class]="classes" ></div>`, isInline: true, dependencies: [{ kind: "pipe", type: AsyncPipe, name: "async" }], encapsulation: i0.ViewEncapsulation.None, preserveWhitespaces: true }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: AnalogMarkdownComponent, decorators: [{ type: Component, args: [{ selector: 'analog-markdown', standalone: true, imports: [AsyncPipe], hostDirectives: [AnchorNavigationDirective], preserveWhitespaces: true, encapsulation: ViewEncapsulation.None, template: `<div #container [innerHTML]="content$ | async" [class]="classes" ></div>`, }] }], ctorParameters: () => [], propDecorators: { content: [{ type: Input }], classes: [{ type: Input }], container: [{ type: ViewChild, args: ['container', { static: true, read: ViewContainerRef }] }] } }); /** * Generated bundle index. Do not edit. */ export { AnchorNavigationDirective, ContentRenderer, MERMAID_IMPORT_TOKEN, AnalogMarkdownComponent as MarkdownComponent, MarkdownContentRendererService, AnalogMarkdownRouteComponent as MarkdownRouteComponent, MarkedContentHighlighter, MarkedSetupService, NoopContentRenderer, injectContent, injectContentFiles, parseRawContentFile, provideContent, withHighlighter, withMarkdownRenderer }; //# sourceMappingURL=analogjs-content.mjs.map