@analogjs/content
Version:
Content Rendering for Analog
777 lines (755 loc) • 32.7 kB
JavaScript
import * as i0 from '@angular/core';
import { inject, HostListener, Directive, Injectable, TransferState, makeStateKey, InjectionToken, signal, ɵPendingTasksInternal as _PendingTasksInternal, Input, ViewEncapsulation, Component, NgZone, PLATFORM_ID, computed, input } 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 { switchMap, map, tap, catchError } from 'rxjs/operators';
import fm from 'front-matter';
import { gfmHeadingId, getHeadingList } from 'marked-gfm-heading-id';
import { marked } from 'marked';
import { mangle } from 'marked-mangle';
import { DomSanitizer } from '@angular/platform-browser';
import { toSignal, 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: "22.0.0", ngImport: i0, type: AnchorNavigationDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.0", type: AnchorNavigationDirective, isStandalone: true, selector: "[analogAnchorNavigation]", host: { listeners: { "click": "handleNavigation($event.target)" } }, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", 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);
}
/// <reference types="vite/client" />
class ContentRenderer {
async render(content) {
return { content, toc: [] };
}
// Backward-compatible API for consumers that read headings directly.
getContentHeadings(_content) {
return [];
}
// eslint-disable-next-line
enhance() { }
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: ContentRenderer, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: ContentRenderer }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", 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);
const toc = this.getContentHeadings(content);
const key = makeStateKey(`content-headings-${this.contentId}`);
if (import.meta.env.SSR === true) {
this.transferState.set(key, toc);
return { content, toc };
}
return {
content,
toc: this.transferState.get(key, toc),
};
}
enhance() { }
getContentHeadings(content) {
return this.extractHeadings(content);
}
extractHeadings(content) {
const markdownHeadings = this.extractHeadingsFromMarkdown(content);
if (markdownHeadings.length > 0) {
return markdownHeadings;
}
const htmlHeadings = this.extractHeadingsFromHtml(content);
return htmlHeadings;
}
extractHeadingsFromMarkdown(content) {
const lines = content.split('\n');
const toc = [];
const slugCounts = new Map();
for (const line of lines) {
const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
if (!match) {
continue;
}
const level = match[1].length;
const text = match[2].trim();
if (!text) {
continue;
}
const baseSlug = text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.trim()
.replace(/\s+/g, '-');
const count = slugCounts.get(baseSlug) ?? 0;
slugCounts.set(baseSlug, count + 1);
const id = count === 0 ? baseSlug : `${baseSlug}-${count}`;
toc.push({ id, level, text });
}
return toc;
}
extractHeadingsFromHtml(content) {
const toc = [];
const slugCounts = new Map();
const headingRegex = /<h([1-6])([^>]*)>([\s\S]*?)<\/h\1>/gi;
for (const match of content.matchAll(headingRegex)) {
const level = Number(match[1]);
const attrs = match[2] ?? '';
const rawInner = match[3] ?? '';
const text = rawInner.replace(/<[^>]+>/g, '').trim();
if (!text) {
continue;
}
const idMatch = /\sid=(['"])(.*?)\1/i.exec(attrs) ?? /\sid=([^\s>]+)/i.exec(attrs);
let id = idMatch?.[2] ?? idMatch?.[1] ?? '';
if (!id) {
id = this.makeSlug(text, slugCounts);
}
toc.push({ id, level, text });
}
return toc;
}
makeSlug(text, slugCounts) {
const baseSlug = text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.trim()
.replace(/\s+/g, '-');
const count = slugCounts.get(baseSlug) ?? 0;
slugCounts.set(baseSlug, count + 1);
return count === 0 ? baseSlug : `${baseSlug}-${count}`;
}
}
/**
* Token for the active content locale.
* Provided via `withLocale()` in `provideContent()`.
*
* When set, `injectContentFiles()` filters to content matching this locale,
* and `injectContent()` resolves locale-prefixed content paths first.
*/
const CONTENT_LOCALE = new InjectionToken('@analogjs/content Locale');
/**
* Injects the content locale, returning null if not configured.
*/
function injectContentLocale() {
return inject(CONTENT_LOCALE, { optional: true });
}
/**
* Content feature that sets the active locale for content resolution.
*
* When provided, content APIs become locale-aware:
* - `injectContentFiles()` filters to files in the locale subdirectory
* or with a matching `locale` frontmatter attribute.
* - `injectContent()` tries locale-prefixed paths first
* (e.g., `content/fr/blog/post.md` before `content/blog/post.md`).
*
* Usage:
* ```typescript
* // With loader — runs in injection context
* provideContent(
* withMarkdownRenderer(),
* withLocale({ loadLocale: injectLocale }),
* )
*
* // Static locale
* provideContent(
* withMarkdownRenderer(),
* withLocale('fr'),
* )
* ```
*/
function withLocale(locale) {
if (typeof locale === 'string') {
return { provide: CONTENT_LOCALE, useValue: locale };
}
return { provide: CONTENT_LOCALE, useFactory: locale.loadLocale };
}
/**
* Filters content files by locale using map-based key lookup.
*
* Matching rules:
* 1. Frontmatter `locale` attribute matches the active locale.
* 2. File is in the active locale subdirectory (e.g., `/content/fr/blog/post`).
* 3. File has no locale marker and no localized variant exists — included as universal content.
*
* Files in a different locale's subdirectory are always excluded.
*/
function filterByLocale(files, locale) {
const localePrefix = `/content/${locale}/`;
// Collect all locale prefixes present in the file set
const allLocalePrefixes = new Set();
for (const file of files) {
const match = file.filename.match(/\/content\/([a-z]{2}(?:-[a-zA-Z]+)?)\//);
if (match) {
allLocalePrefixes.add(`/content/${match[1]}/`);
}
}
// Build set of base paths that have a localized variant for the active locale
const localizedBasePaths = new Set();
for (const file of files) {
if (file.filename.includes(localePrefix)) {
localizedBasePaths.add(file.filename.replace(localePrefix, '/content/'));
}
}
return files.filter((file) => {
// Frontmatter locale attribute takes priority
if (file.attributes['locale']) {
return file.attributes['locale'] === locale;
}
// File is in the active locale subdirectory — include
if (file.filename.includes(localePrefix)) {
return true;
}
// File is in a different locale's subdirectory — exclude
for (const prefix of allLocalePrefixes) {
if (prefix !== localePrefix && file.filename.includes(prefix)) {
return false;
}
}
// Universal content — include only if no localized variant exists
return !localizedBasePaths.has(file.filename);
});
}
/**
* Prepends locale-prefixed candidates before the standard candidates
* for content file map key lookup.
*/
function withLocaleCandidates(candidates, locale) {
if (!locale) {
return candidates;
}
const localeCandidates = candidates.map((c) => c.replace('/src/content/', `/src/content/${locale}/`));
return [...localeCandidates, ...candidates];
}
/**
* 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;
};
function getSlug(filename) {
// Extract the last path segment without its extension.
// Handles names with dots like [[...slug]].md by stripping only the final extension.
const lastSegment = (filename.split(/[/\\]/).pop() || '').trim();
const base = lastSegment.replace(/\.[^./\\]+$/, ''); // strip only the final extension
// Treat index.md as index route => empty slug
return base === 'index' ? '' : base;
}
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 allFiles = { ...contentFiles };
const contentFilesList = inject(CONTENT_FILES_LIST_TOKEN);
const lookup = {};
contentFilesList.forEach((item) => {
const contentFilename = item.filename.replace(/(.*?)\/content/, '/src/content');
const fileParts = contentFilename.split('/');
const filePath = fileParts.slice(0, fileParts.length - 1).join('/');
const fileNameParts = fileParts[fileParts.length - 1].split('.');
const ext = fileNameParts[fileNameParts.length - 1];
let slug = (item.slug ?? '');
// Default empty slug to 'index'
if (slug === '') {
slug = 'index';
}
// Slugs with path separators are scoped to the file's top-level
// subdirectory (e.g. `/src/content/docs`) rather than absolute root,
// so nested files keep resolving through `subdirectory` lookups.
// Files directly under `/src/content` keep the original root-relative
// behavior since they have no subdirectory of their own.
const subdirRoot = fileParts.length > 4 ? fileParts.slice(0, 4).join('/') : '/src/content';
const newBase = slug.includes('/')
? `${subdirRoot}/${slug}`
: `${filePath}/${slug}`;
lookup[contentFilename] = `${newBase}.${ext}`.replace(/\/{2,}/g, '/');
});
const objectUsingSlugAttribute = {};
Object.entries(allFiles).forEach((entry) => {
const filename = entry[0];
const value = entry[1];
const strippedFilename = filename.replace(/^\/(.*?)\/content/, '/src/content');
const newFilename = lookup[strippedFilename];
if (newFilename !== undefined) {
const objectFilename = newFilename.replace(/^\/(.*?)\/content/, '/src/content');
objectUsingSlugAttribute[objectFilename] =
value;
}
});
return objectUsingSlugAttribute;
},
});
const CONTENT_FILES_MAP_TOKEN = new InjectionToken('@analogjs/content Content Files', {
providedIn: 'root',
factory() {
return signal(inject(CONTENT_FILES_TOKEN));
},
});
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: "22.0.0", ngImport: i0, type: RenderTaskService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: RenderTaskService }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: RenderTaskService, decorators: [{
type: Injectable
}] });
/// <reference types="vite/client" />
function getContentFile(contentFiles, prefix, slug, fallback, renderTaskService, contentRenderer, locale) {
// Normalize file keys so both "/src/content/..." and "/<project>/src/content/..." resolve.
const normalizedFiles = {};
for (const [key, resolver] of Object.entries(contentFiles)) {
const normalizedKey = key
.replace(/^(?:.*)\/content/, '/src/content')
.replace(/\/{2,}/g, '/');
normalizedFiles[normalizedKey] = resolver;
}
const base = `/src/content/${prefix}${slug}`.replace(/\/{2,}/g, '/');
const candidates = [
`${base}.md`,
`${base}.agx`,
`${base}/index.md`,
`${base}/index.agx`,
];
const allCandidates = withLocaleCandidates(candidates, locale);
const matchKey = allCandidates.find((k) => k in normalizedFiles);
const contentFile = matchKey ? normalizedFiles[matchKey] : undefined;
const resolvedBase = (matchKey || `${base}.md`).replace(/\.(md|agx)$/, '');
if (!contentFile) {
return of({
filename: resolvedBase,
attributes: {},
slug: '',
content: fallback,
toc: [],
});
}
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(switchMap((contentFile) => {
if (typeof contentFile === 'string') {
const { content, attributes } = parseRawContentFile(contentFile);
return from(contentRenderer.render(content)).pipe(map((rendered) => ({
filename: resolvedBase,
slug,
attributes,
content,
toc: rendered.toc ?? [],
})));
}
return of({
filename: resolvedBase,
slug,
attributes: contentFile.metadata,
content: contentFile.default,
toc: [],
});
}));
}
/**
* 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 contentRenderer = inject(ContentRenderer);
const renderTaskService = inject(RenderTaskService);
const locale = inject(CONTENT_LOCALE, { optional: true });
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, contentRenderer, locale);
}
return of({
filename: '',
slug: '',
attributes: {},
content: fallback,
toc: [],
});
}), tap(() => renderTaskService.clearRenderTask(task)));
}
else {
return getContentFile(contentFiles, '', param.customFilename, fallback, renderTaskService, contentRenderer, locale).pipe(tap(() => renderTaskService.clearRenderTask(task)));
}
}
function injectContentFiles(filterFn) {
const renderTaskService = inject(RenderTaskService);
const task = renderTaskService.addRenderTask();
const allContentFiles = inject(CONTENT_FILES_LIST_TOKEN);
const locale = inject(CONTENT_LOCALE, { optional: true });
renderTaskService.clearRenderTask(task);
let results = allContentFiles;
if (locale) {
results = filterByLocale(results, locale);
}
if (filterFn) {
results = results.filter(filterFn);
}
return results;
}
function injectContentFilesMap() {
return inject(CONTENT_FILES_TOKEN);
}
class MarkedContentHighlighter {
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: MarkedContentHighlighter, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: MarkedContentHighlighter }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", 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: "22.0.0", ngImport: i0, type: MarkedSetupService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: MarkedSetupService }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: MarkedSetupService, decorators: [{
type: Injectable
}], ctorParameters: () => [] });
class MarkdownContentRendererService {
#marked = inject(MarkedSetupService, { self: true });
async render(content) {
const renderedContent = await this.#marked
.getMarkedInstance()
.parse(content);
return {
content: renderedContent,
toc: getHeadingList(),
};
}
getContentHeadings(content) {
return [...content.matchAll(/^(#{1,6})\s+(.+?)\s*$/gm)].map((match) => ({
id: match[2]
.trim()
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-'),
level: match[1].length,
text: match[2].trim(),
}));
}
// eslint-disable-next-line
enhance() { }
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: MarkdownContentRendererService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: MarkdownContentRendererService }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: MarkdownContentRendererService, decorators: [{
type: Injectable
}] });
const CONTENT_FILE_LOADER = new InjectionToken('@analogjs/content/resource File Loader');
function injectContentFileLoader() {
return inject(CONTENT_FILE_LOADER);
}
function withContentFileLoader() {
return {
provide: CONTENT_FILE_LOADER,
useFactory() {
return async () => injectContentFilesMap();
},
};
}
const CONTENT_LIST_LOADER = new InjectionToken('@analogjs/content/resource List Loader');
function injectContentListLoader() {
return inject(CONTENT_LIST_LOADER);
}
function withContentListLoader() {
return {
provide: CONTENT_LIST_LOADER,
useFactory() {
return async () => injectContentFiles();
},
};
}
const CONTENT_RENDERER_PROVIDERS = [
{
provide: ContentRenderer,
useClass: NoopContentRenderer,
},
withContentFileLoader(),
withContentListLoader(),
];
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: "22.0.0", ngImport: i0, type: AnalogMarkdownRouteComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.0.0", 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: "22.0.0", 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.contentSource = toSignal(this.getContentSource());
this.htmlContent = computed(() => {
const inputContent = this.content();
if (inputContent) {
return this.sanitizer.bypassSecurityTrustHtml(inputContent);
}
return this.contentSource();
}, /* @ts-ignore */
...(ngDevMode ? [{ debugName: "htmlContent" }] : /* istanbul ignore next */ []));
this.content = input(/* @ts-ignore */
...(ngDevMode ? [undefined, { debugName: "content" }] : /* istanbul ignore next */ []));
this.classes = input('analog-markdown', /* @ts-ignore */
...(ngDevMode ? [{ debugName: "classes" }] : /* istanbul ignore next */ []));
this.contentRenderer = inject(ContentRenderer);
if (isPlatformBrowser(this.platformId) && this.mermaidImport) {
// Mermaid can only be loaded on client side
this.loadMermaid(this.mermaidImport);
}
}
getContentSource() {
return this.route.data.pipe(map((data) => data['_analogContent'] ?? ''), switchMap((contentString) => this.renderContent(contentString)), map((content) => this.sanitizer.bypassSecurityTrustHtml(content)), catchError((e) => of(`There was an error ${e}`)));
}
async renderContent(content) {
const rendered = await this.contentRenderer.render(content);
return rendered.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: "22.0.0", ngImport: i0, type: AnalogMarkdownComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.0.0", type: AnalogMarkdownComponent, isStandalone: true, selector: "analog-markdown", inputs: { content: { classPropertyName: "content", publicName: "content", isSignal: true, isRequired: false, transformFunction: null }, classes: { classPropertyName: "classes", publicName: "classes", isSignal: true, isRequired: false, transformFunction: null } }, hostDirectives: [{ directive: AnchorNavigationDirective }], ngImport: i0, template: ` <div [innerHTML]="htmlContent()" [class]="classes()"></div> `, isInline: true, encapsulation: i0.ViewEncapsulation.None, preserveWhitespaces: true }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: AnalogMarkdownComponent, decorators: [{
type: Component,
args: [{
selector: 'analog-markdown',
standalone: true,
hostDirectives: [AnchorNavigationDirective],
preserveWhitespaces: true,
encapsulation: ViewEncapsulation.None,
template: ` <div [innerHTML]="htmlContent()" [class]="classes()"></div> `,
}]
}], ctorParameters: () => [], propDecorators: { content: [{ type: i0.Input, args: [{ isSignal: true, alias: "content", required: false }] }], classes: [{ type: i0.Input, args: [{ isSignal: true, alias: "classes", required: false }] }] } });
/**
* Generated bundle index. Do not edit.
*/
export { AnchorNavigationDirective, CONTENT_FILE_LOADER, CONTENT_LIST_LOADER, CONTENT_LOCALE, ContentRenderer, MERMAID_IMPORT_TOKEN, AnalogMarkdownComponent as MarkdownComponent, MarkdownContentRendererService, AnalogMarkdownRouteComponent as MarkdownRouteComponent, MarkedContentHighlighter, MarkedSetupService, NoopContentRenderer, filterByLocale, injectContent, injectContentFileLoader, injectContentFiles, injectContentFilesMap, injectContentListLoader, injectContentLocale, parseRawContentFile, provideContent, withContentFileLoader, withContentListLoader, withHighlighter, withLocale, withLocaleCandidates, withMarkdownRenderer };
//# sourceMappingURL=analogjs-content.mjs.map