github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/cmd/deck/static/spyglass/lens.ts (about)

     1  import {parseQuery} from '../common/urls';
     2  import {isResponse, isTransitMessage, isUpdateHashMessage, Message, Response, serialiseHashes} from './common';
     3  
     4  // Solution is inspired by https://stackoverflow.com/questions/29055828/regex-to-make-links-clickable-in-only-a-href-and-not-img-src
     5  const linkRegex = /((?:href|src)=")?(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig;
     6  
     7  export interface Spyglass {
     8    /**
     9     * Replaces the lens display with a new server-rendered page.
    10     * The returned promise will be resolved once the page has been updated.
    11     *
    12     * @param data Some data to pass back to the server. JSON encoding is
    13     * recommended, but not required.
    14     */
    15    updatePage(data: string): Promise<void>;
    16    /**
    17     * Requests that the server re-render the lens with the provided data, and
    18     * returns a promise that will resolve with that HTML as a string.
    19     *
    20     * This is equivalent to updatePage(), except that the displayed content is
    21     * not automatically changed.
    22     *
    23     * @param data Some data to pass back to the server. JSON encoding is
    24     * recommended, but not required.
    25     */
    26    requestPage(data: string): Promise<string>;
    27    /**
    28     * Sends a request to the server-side lens backend with the provided data, and
    29     * returns a promise that will resolve with the response as a string.
    30     *
    31     * @param data Some data to pass back to the server. JSON encoding is
    32     * recommended, but not required.
    33     */
    34    request(data: string): Promise<string>;
    35    /**
    36     * Inform Spyglass that the lens content has updated. This should be called whenever
    37     * the visible content changes, so Spyglass can ensure that all content is visible.
    38     */
    39    contentUpdated(): void;
    40    /**
    41     * Returns a top-level URL that will cause your lens to be loaded with the
    42     * specified fragment. This is useful to construct copyable links, but generally
    43     * should not be used for immediate navigation.
    44     *
    45     * @param fragment The fragment you want. If not prefixed with a #, one will
    46     * be assumed.
    47     */
    48    makeFragmentLink(fragment: string): string;
    49  
    50    /**
    51     * Scrolls the parent window so that the specified coordinates are visible.
    52     *
    53     * @param x The x coordinate relative to the lens document to scroll to.
    54     * @param y The y coordinate relative to the lens document to scroll to.
    55     */
    56    scrollTo(x: number, y: number): Promise<void>;
    57  }
    58  
    59  class SpyglassImpl implements Spyglass {
    60    private pendingRequests = new Map<number, (v: Response) => void>();
    61    private messageId = 0;
    62    private pendingUpdateTimer = 0;
    63    private currentHash = '';
    64    private observer: MutationObserver;
    65  
    66    constructor() {
    67      this.currentHash = location.hash;
    68      this.observer = new MutationObserver((mutations) => this.handleMutations(mutations));
    69  
    70      window.addEventListener('message', (e) => this.handleMessage(e));
    71      window.addEventListener('hashchange', (e) => this.handleHashChange(e));
    72      window.addEventListener('DOMContentLoaded', () => {
    73        this.createHyperlinks(document.documentElement);
    74        this.fixAnchorLinks(document.documentElement);
    75        this.observer.observe(document.documentElement, {attributeFilter: ['href'], childList: true, subtree: true});
    76      });
    77      window.addEventListener('load', () => {
    78        this.contentUpdated();
    79        // this needs a delay but I'm not sure what (if anything) we're racing.
    80        setTimeout(() => {
    81          if (location.hash !== '') {
    82            this.tryMoveToHash(location.hash);
    83          }
    84        }, 100);
    85      });
    86    }
    87  
    88    public async updatePage(data: string): Promise<void> {
    89      await this.postMessage({type: 'updatePage', data});
    90      this.contentUpdated();
    91    }
    92    public async requestPage(data: string): Promise<string> {
    93      const result = await this.postMessage({type: 'requestPage', data});
    94      return result.data;
    95    }
    96    public async request(data: string): Promise<string> {
    97      const result = await this.postMessage({type: 'request', data});
    98      return result.data;
    99    }
   100    public contentUpdated(): void {
   101      this.updateHeight();
   102      clearTimeout(this.pendingUpdateTimer);
   103      // to be honest I have zero understanding of why this helps, but apparently it does.
   104      this.pendingUpdateTimer = setTimeout(() => this.updateHeight(), 0);
   105    }
   106  
   107    public makeFragmentLink(fragment: string): string {
   108      const q = parseQuery(location.search.substr(1));
   109      const topURL = q.topURL!;
   110      const lensIndex = q.lensIndex!;
   111      if (fragment[0] !== '#') {
   112        fragment = `#${  fragment}`;
   113      }
   114      return `${topURL}#${serialiseHashes({[lensIndex]: fragment})}`;
   115    }
   116  
   117    public async scrollTo(x: number, y: number): Promise<void> {
   118      await this.postMessage({type: 'showOffset', left: x, top: y});
   119    }
   120  
   121    private updateHeight(): void {
   122      // .then() to suppress complaints about unhandled promises (we just don't care here).
   123      this.postMessage({type: 'contentUpdated', height: document.body.offsetHeight}).then();
   124    }
   125  
   126    private postMessage(message: Message): Promise<Response> {
   127      return new Promise<Response>((resolve, reject) => {
   128        const id = ++this.messageId;
   129        this.pendingRequests.set(id, resolve);
   130        window.parent.postMessage({id, message}, document.location.origin);
   131      });
   132    }
   133  
   134    private handleMessage(e: MessageEvent): void {
   135      if (e.origin !== document.location.origin) {
   136        console.warn(`Got MessageEvent from unexpected origin ${e.origin}; expected ${document.location.origin}`, e);
   137        return;
   138      }
   139      const data = e.data;
   140      if (isTransitMessage(data)) {
   141        if (isResponse(data.message)) {
   142          if (this.pendingRequests.has(data.id)) {
   143            this.pendingRequests.get(data.id)!(data.message);
   144            this.pendingRequests.delete(data.id);
   145          }
   146        }
   147      } else if (isUpdateHashMessage(data)) {
   148        location.hash = data.hash;
   149      }
   150    }
   151  
   152    // When any links on the page are added or mutated, we fix them up if they
   153    // were anchor links to avoid developer confusion.
   154    private handleMutations(mutations: MutationRecord[]): void {
   155      for (const mutation of mutations) {
   156        if (!(mutation.target instanceof HTMLElement)) {
   157          continue;
   158        }
   159        if (mutation.type === 'childList') {
   160          this.fixAnchorLinks(mutation.target);
   161  
   162          if (mutation.target instanceof HTMLDivElement &&
   163              (mutation.target.classList.contains('shown') ||
   164               mutation.target.classList.contains('loglines'))) {
   165            this.createHyperlinks(mutation.target);
   166          }
   167        } else if (mutation.type === 'attributes') {
   168          if (mutation.target instanceof HTMLAnchorElement && mutation.attributeName === 'href') {
   169            const href = mutation.target.getAttribute('href');
   170            if (href && href[0] === '#') {
   171              this.fixAnchorLink(mutation.target);
   172            }
   173          }
   174        }
   175      }
   176    }
   177  
   178    private handleHashChange(e: HashChangeEvent): void {
   179      if (location.hash === this.currentHash) {
   180        return;
   181      }
   182      this.currentHash = location.hash;
   183      this.postMessage({type: 'updateHash', hash: location.hash}).then();
   184      this.tryMoveToHash(location.hash);
   185    }
   186  
   187    // Because we're in an iframe that is exactly our height, anchor links don't
   188    // actually do anything (and even if they did, it would not be something
   189    // useful). We implement their intended behaviour manually by looking up the
   190    // element referred to and requesting that our parent jump to that offset.
   191    private tryMoveToHash(hash: string): void {
   192      hash = hash.substr(1);
   193      let el = document.getElementById(hash);
   194      if (!el) {
   195        el = document.getElementsByName(hash)[0];
   196        if (!el) {
   197          return;
   198        }
   199      }
   200      const top = el.getBoundingClientRect().top + window.pageYOffset;
   201      this.scrollTo(0, top).then();
   202    }
   203  
   204    private setLink(match: string, attr: string): string {
   205      if (typeof attr !== 'undefined') {
   206        return match;
   207      }
   208      return `</span><a target="_blank" href="${match}">${match}</a><span>`;
   209    }
   210  
   211    private createHyperlinks(parent: Element): void {
   212      for (const elem of Array.from(parent.querySelectorAll<HTMLElement>('div.linetext>span'))) {
   213        this.createHyperlink(elem);
   214      }
   215    }
   216  
   217    private createHyperlink(elem: HTMLElement): void {
   218      // Doing a light match check before running heavier replace regex manipulation
   219      if (elem.innerText.match(linkRegex)) {
   220        /* eslint-disable  @typescript-eslint/unbound-method */
   221        elem.innerHTML = elem.innerText.replace(linkRegex, this.setLink);
   222      }
   223    }
   224  
   225    // We need to fix up anchor links (i.e. links that only set the fragment)
   226    // because we use <base> to make asset references Just Work, but that also
   227    // applies to anchor links, which is surprising to developers.
   228    // In order to mitigate this surprise, we find all the links that were
   229    // supposed to be anchor links and fix them by adding the absolute URL
   230    // of the current page.
   231    private fixAnchorLinks(parent: Element): void {
   232      for (const a of Array.from(parent.querySelectorAll<HTMLAnchorElement>('a[href^="#"]'))) {
   233        this.fixAnchorLink(a);
   234      }
   235    }
   236  
   237    private fixAnchorLink(a: HTMLAnchorElement): void {
   238      if (!a.dataset.preserveAnchor) {
   239        a.href = location.href.split('#')[0] + a.getAttribute('href');
   240        a.target = "_self";
   241      }
   242    }
   243  }
   244  
   245  const spyglass = new SpyglassImpl();
   246  (window as any).spyglass = spyglass;