RSS Git Download  Clone
Raw Blame History 6kB 178 lines
import {
    Component,
    NgZone,
    AfterViewChecked,
    Inject,
    effect,
    computed,
    resource,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { httpResource } from '@angular/common/http';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { DOCUMENT } from '@angular/common';

import { ActivatedRoute, Params, UrlSegment } from '@angular/router';

import { Layout } from '../layout';

import { CdnService, MarkdownService } from '../service';

import { LocaleService, SettingsService } from '../modules/web';

import twemoji from 'twemoji';

import { NotifyService } from '../modules/material';
import { environment } from '../../environments/environment';

let readyNotified = false;

@Component({
    selector: 'cory-page',
    template: `
        <span id="cory-page-content" [innerHTML]="safeContent"></span>
    `,
    standalone: true,
})
export class Page implements AfterViewChecked {

    loaded = false;

    safeContent: SafeHtml = '';

    constructor(
        private markdown: MarkdownService,
        private cdn: CdnService,
        private activatedRoute: ActivatedRoute,
        /* Used by MarkdownService via this.context.settings — keep the
           injection even though Page.ts itself never reads it. */
        public settings: SettingsService,
        private zone: NgZone,
        protected notify: NotifyService,
        protected locale: LocaleService,
        private sanitizer: DomSanitizer,
        @Inject(DOCUMENT) private document: Document,
        private parent: Layout,
    ) {
        this.markdown.context = this;

        if (typeof window !== 'undefined') {
            (window as any)['coryPageCopy'] = (codeId: any) => {
                this.zone.run(() => {
                    const codeEl = this.document.getElementById(`code-${codeId}`);
                    if (codeEl && typeof navigator !== 'undefined' && navigator.clipboard) {
                        navigator.clipboard.writeText(codeEl.innerText);
                        this.notify.info('Copied!');
                    }
                });
            };
        }

        /* Signal-native routing:
             - urlSig  tracks the child's own url segments (sub-page nav /repo/foo)
             - paramsSig tracks the parent :repo param (cross-repo nav, where
               the child's segments stay []). */
        const parentRoute = this.activatedRoute.parent ?? this.activatedRoute;
        const urlSig = toSignal(this.activatedRoute.url, { initialValue: [] as UrlSegment[] });
        const paramsSig = toSignal(parentRoute.params, { initialValue: {} as Params });

        const path = computed(() => {
            const joined = urlSig().map(s => s.path).join('/');
            return joined || 'index.html';
        });
        const repo = computed(() => paramsSig()['repo'] as string | undefined);

        /* Reactive CDN URL — recomputes when (repo, path) changes. */
        const cdnUrl = computed(() => {
            const r = repo();
            const p = path();
            if (!r) return undefined;
            return this.cdn.url(r, p);
        });

        /* httpResource handles the fetch, transfer-state caching on SSR,
           cancellation when the URL changes, and exposes value/error/isLoading
           as signals. */
        const cdnText = httpResource.text(() => cdnUrl());

        /* resource() projects (text, path) into rendered markdown HTML. It
           auto-cancels the previous in-flight markdown render when the inputs
           change, so a fast repo-switch won't race. */
        type RenderParams = {
            text: string | undefined;
            error: Error | undefined;
            path: string;
        };
        const renderedHtml = resource<string, RenderParams>({
            params: () => ({
                text: cdnText.value(),
                error: cdnText.error(),
                path: path(),
            }),
            defaultValue: '',
            loader: async ({ params }) => {
                if (params.error) throw params.error;
                if (!params.text) return '';

                const pLower = params.path.toLowerCase();
                let text = params.text;

                if (pLower.endsWith('.json')) {
                    text = `\n\`\`\`json\n${text}\n\`\`\`\n`;
                } else if (pLower.endsWith('.yml')) {
                    text = `\n\`\`\`yaml\n${text}\n\`\`\`\n`;
                } else if (pLower.endsWith('.conf')) {
                    text = `\n\`\`\`nginxconf\n${text}\n\`\`\`\n`;
                }

                return await this.markdown.render(text, this.parent, pLower);
            },
        });

        /* Apply twemoji + sanitize whenever the rendered HTML or error changes. */
        effect(() => {
            const html: string = renderedHtml.value() ?? '';
            const err = renderedHtml.error();

            const body: string = err ? this.render404(err) : html;

            const options = environment.production
                ? { folder: 'svg', ext: '.svg', base: '/assets/twemoji/' }
                : { folder: 'svg', ext: '.svg', base: 'http://twemoji.maxcdn.com/v/latest/' };

            const withEmoji = twemoji.parse(body, options);
            this.safeContent = this.sanitizer.bypassSecurityTrustHtml(withEmoji);
        });
    }

    private render404(e: any): string {
        const msg = e?.message ?? '';
        const loc = typeof location !== 'undefined' ? location.toString() : '';
        const i18n404 = this.locale.data?.material?.http?.['404'] ?? 'Not found';
        return `
            <div style="margin-top: 20px; font-size: 6em; opacity: 0.25;" status-code="404">
                404
            </div>
            <div style="font-size: 3em; opacity: 0.75;">
                <i class="fas fa-thumbs-down"></i> ${i18n404}
            </div>
            <div style="text-overflow: ellipsis; overflow: hidden; opacity: 0.7">${loc}</div>
            <br/>
            <div style="opacity: 0.5">${msg}</div>
        `;
    }

    ngAfterViewChecked() {
        const hash = typeof location !== 'undefined' ? location.hash : '';
        const e = hash ? this.document.querySelector(`${hash}-parent`) : null;
        if (!this.loaded && e) {
            this.loaded = true;
            e.scrollIntoView({ block: 'center' });
        }
        if (!readyNotified) {
            readyNotified = true;
            this.notify.info(this.parent.i18n.title.ready);
        }
    }
}