@analogjs/content
Version:
Content Rendering for Analog
535 lines (516 loc) • 22.5 kB
JavaScript
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