Diving into web components


 When working on the Apie library I ran into the problem that different frameworks render the exceptions differently and I wanted more control of the error page in apie/cms.

I looked at PHP libraries that are made for exceptions, but sadly there are few options. The best option I found was spatie/ignition, which is also the error page used in Laravel. While this library renders exceptions, it renders the complete HTML page, while I wanted to display the exception in the CMS layout. I did 'hack' it into the page by just extracting the <body> part of what was rendered, but it was not ideal. Spatie/ignition also did not render chained exceptions and the source code display was not working out of the box.

Package apie/cms that creates a CMS from domain objects requires a layout package and there is only one CMS Layout package available at the moment, called apie/cms-layout-graphite. This option is using the Graphite Design System that I use internally at my work (but the design system is public). Only the layout is handled with minimum css and the javascript is not using any framework at all. Graphite Design System is made in Stencil, so I thought about making my own stack trace render web component.

What is a webcomponent?

Basically a webcomponent is a custom HTML tag that is being rendered/handled by Javascript. So instead of rendering a large server-side exception message with a huge HTML template we have our own custom HTML tag to render the stack trace:
<apie-stacktrace
    trace='[{"message": "Exception message"},{"message": "Chained message"}]'>
</apie-stacktrace
An additional advantage is that these web components work standalone, so they require no bootstrapping or large framework code. There is only a small bootstrapping at the start when the javascript is loaded and the web component is registered to the browser.

Setting up a stencil project

I followed the instructions of Stencil to make my own web component. The instructions were simple enough. I have worked before with Angular and React so it was relatively easy to understand the concepts with the @Component decorators and JSX templates.
It always creates a 'my-component' component, but we want an apie-stacktrace component. Luckily you can create a new component very easily from the cli:
node node_modules/.bin/stencil g apie-stacktrace
The only weird thing is that it moves the tests to a subfolder, while the stencil init moved the tests in the same folder? But oh well, just a small detail....
Nontheless, I could write my first web component! This is an example of a Stencil component:
import { Component, Prop, h } from '@stencil/core';

@Component({
  tag: 'apie-class-display',
  styleUrl: 'apie-class-display.css',
  shadow: true,
})
export class ApieClassDisplay {

  @Prop() phpClassName: string;

  render() {
    const parts = this.phpClassName.split('\\');
    const lastIndex = parts.length - 1;
    return (
      <div class="php-class-container">
        {parts.map((part, index) => (
          <span class="php-class-part" key="{index}">
            {part} { index !== lastIndex && <span> \ </span>}
          </span>
        ))}
      </div>
    );
  }
}
If you are used to plain Javascript, this will look alien to you. First of all it's Typescript, so we can give typehints to properties. Typescript also supports ES7 decorators which are the @Component and @Prop you see. With this Stencil webcomponent loaded I can write <apie-class-display phpClassName="App\Example\ExampleService" /> in the HTML and it will display a class name with some styling.
The HTML part is familiar for people that work with react as it's a JSX template that merges Javascript with the template HTML. The CSS is kept in a separate file, but the CSS will only be scoped to this template, so it can not have effects of anything outside this component. And that is what we really need for a PHP exception renderer.

Progress

Defined web components

I came up with these components using spatie/ignition as a basis how a stacktrace display should be shown on the screen:
  • <apie-stacktrace> renders full stacktrace
  • <apie-class-display> renders a fully qualified class name with some styling (it's the example above here).
  • <apie-exception-display> renders a single exception message without the stacktrace.
  • <apie-render-code> renders the source code.
  • <apie-stack-display> renders the stack and displays the source code if you click on a row.

Array properties

The first issue I ran into is that I tried <apie-stacktrace exceptions='{}' /> but it seems no objects or arrays are serialized into an object. Reading the documentations I read the recommended way to do what I want is by doing it with javascript:
<apie-stacktrace />
<script>document.querySelector('apie-stacktrace').exception = {}</script>
We could have made a string prop and use JSON.parse internally, but I thought this is acceptable for now. After all JSON contains " and it could be annoying to have to escape all " into &quot;

Reading source codes

Reading source codes is optional as it could be a security issue if you expose source files. For example Symfony will never render the generated service container in a Symfony application to avoid accidentally exposing the secrets. Instead of sending it with a large JSON structure I used <template> html:
<template type="apie/stacktrace-source" id="/var/www/html/dummy.php">&lt;?php
// rest of php source code with < being encoded as &lt;
</template>

I wrote a small utils function to extract templates and give PHP code as plaintext. Getting the contents in a <template> as a string was quite troublesome as I ran into double entity decoding.

This is my function:
export function getFileContents(fileName: string): string | null {
  for (const templateElm of document.querySelectorAll('template') as any) {
    if (templateElm.getAttribute('type') === 'apie/stacktrace-source' && templateElm.id === fileName) {
      const divElement = document.createElement('div');
      divElement.appendChild(templateElm.cloneNode(true));
      var el = document.createElement('div');
      return String(templateElm.innerHTML).replace(/\&[#0-9a-z]+;/gi, function (enc) {
        el.innerHTML = enc;
        return el.innerText
      });
    }
  }
  return null;
}
This function iterates over all <template> tags with a type attribute and the id matching the filename. The rest of the function is basically DOM manipulation to get the template as a string without encoding HTML entities twice.

First version

With some finetuning and checking how spatie/ignition handled the styling I had a first version. So we could use this in Apie with loading a single javascript file and all styling is only applied to the stacktrace.

Styling/theming

The original styling was almost a 1 on 1 copy of spatie/ignition's stacktrace. Ignition is using tailwind and since I do not want many dependencies I replaced all of them to inline css.
The next step would be to make css variables. Why? Because web components use a Shadow DOM, you can not style a webcomponent outside the component at all!

However we can use css variables. First you need a :root selector that sets up all global CSS variables. These CSS variables are also available in the Shadow DOM.
We need to setup a global CSS file in our Stencil project and create a global CSS file to define the default values.
:root {
    --apie-stacktrace-class-display-background: rgba(107,114,128,.05);
    --apie-stacktrace-background: rgba(255, 255, 255, 1);
    --apie-stacktrace-border-color: rgba(229,231,235,1);
    --apie-stacktrace-trace-hover-color: rgba(239,68,68,.1);
    --apie-stacktrace-trace-active-background-color: rgba(239,68,68,1);
    --apie-stacktrace-trace-active-text-color: rgba(255,255,255,1);
    --apie-stacktrace-code-text-color: #999;
    --apie-stacktrace-code-highlight-color: #ffd;
    --apie-stacktrace-code-background-color: transparent;
    --apie-shadow-color: rgba(107,114,128,0.2);
    --apie-shadow-colored: 0 25px 50px -12px var(--apie-shadow-color);
}
Now in our webcomponent we can use this CSS variable like this:
// in web component css:
div.highlight { color: var(--apie-highlight-color) }
We could load the global CSS or we could define our own CSS Theme now!

// theme our stacktrace:
apie-stacktrace { ---apie-highlight-color: blue; }
Any of these CSS variables can be set in the Application in a <style> tag inside the <head> of the HTML:
default
* {
    --apie-stacktrace-class-display-background: rgba(107,114,128,.05);
    --apie-stacktrace-background: rgba(255, 255, 255, 1);
    --apie-stacktrace-border-color: rgba(229,231,235,1);
    --apie-stacktrace-trace-hover-color: rgba(239,68,68,.1);
    --apie-stacktrace-trace-active-background-color: rgba(239,68,68,1);
    --apie-stacktrace-trace-active-text-color: rgba(255,255,255,1);
    --apie-stacktrace-code-text-color: #999;
    --apie-stacktrace-code-highlight-color: #ffd;
    --apie-stacktrace-code-background-color: transparent;
    --apie-shadow-color: rgba(107,114,128,0.2);
    --apie-stacktrace-php-version: rgba(107,114,128,0.75);
    --apie-shadow-colored: 0 25px 50px -12px var(--apie-shadow-color);
}
crazy yellow
* {
    --apie-stacktrace-class-display-background: rgb(255, 6, 6);
    --apie-stacktrace-background: rgb(255, 255, 0);
    --apie-stacktrace-border-color: rgba(255, 0, 0, 0.9);
    --apie-shadow-color: rgba(0,0,0,0.75);
    --apie-shadow-colored: 0 125px 150px -120px var(--apie-shadow-color);
    --apie-stacktrace-trace-hover-color: rgba(239,239,68,.1);
    --apie-stacktrace-trace-active-background-color: rgba(0,239,68,1);
    --apie-stacktrace-trace-active-text-color: rgba(255,0,0,1);
    --apie-stacktrace-code-text-color: #ff8181;
    --apie-stacktrace-code-highlight-color: rgb(255, 166, 0);
    --apie-stacktrace-code-background-color: rgba(255, 255, 255, 0.5);
    --apie-stacktrace-php-version: rgba(255, 166, 0, 1);
}
boring gray
* {
    --apie-stacktrace-class-display-background: #888;
    --apie-stacktrace-background: #FFF;
    --apie-stacktrace-border-color: #222;
    --apie-stacktrace-trace-hover-color: rgba(255, 255, 255,.1);
    --apie-stacktrace-trace-active-background-color: rgba(192, 192, 192, 0.5);
    --apie-stacktrace-trace-active-text-color: #666;
    --apie-stacktrace-code-text-color: #888;
    --apie-stacktrace-code-highlight-color: #AAA;
    --apie-stacktrace-code-background-color: #999;
    --apie-shadow-color: transparent;
    --apie-stacktrace-php-version: #888;
    --apie-shadow-colored: 0 0 0 0 var(--apie-shadow-color);
}
So now we have our lovely webcomponent and we could style it a little in our application if required.

Publish to NPM

I made an account and used the default github actions workflow to publish a new version. I'm used to Composer where you just provide a github URL, but for NPM you build the files to a 'dist' folder and upload these to npmjs.com. You also have to provide the version number in package.json. Reusing the same version number for a new version will be refused by npmjs. And we have our npm package: https://www.npmjs.com/package/apie-stacktrace

Using the component in PHP

I just want to be able to render a PHP Throwable with a single PHP call and output raw HTML as a string without any dependencies. I only need it in apie/html-builders which is an undocumented internal package for Apie CMS to build up pages (and it's also on the 1.0.0 branch, not main). For now that's good enough. If there is a lot of demand I will probably move it to it's own package, maybe even add configuration as the script and global css will always be loaded with a hardcoded CDN url found on unpkg.com.

As for usage, this is the easiest call to get a fancy PHP stacktrace rendered in PHP:

use Apie\HtmlBuilders\ErrorHandler\StacktraceRenderer;

$renderer = new StacktraceRenderer(new \Exception('this is a test'));
echo (string) $renderer;
That's all there is to it!

Conclusion

Working with Stencil is quite easy. It's advised you are used to Typescript, JSX and ES7 decorators as they could be learning curves. For a more backend oriented developer Stencil is really nice. The only thing catching me of guard is how to set up the css variables in conjunction with the Shadow DOM and I had to pay some good attention to the documentation. I hope this error renderer will be beneficial in the future. I'm not a designer, so if someone wants to improve it, feel free to make pull requests. It also misses a readme and tests.

Comments