/** A zero dependency web component that will display JavaScript console output on-page.
*
* Version See Class version property
*
*/
/** Copyright (c) 2024-2025 Julian Knight (Totally Information)
* https://it.knightnet.org.uk, https://github.com/TotallyInformation
*
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** TODO
* - [STARTED] Add actual calling fn/line number to the output. Add extra line to console output and a tooltip or similar for the visible display.
* - Add part transparent color backgrounds for at least warn and err
* - std parts
* - allow to be separate from console (via an attribute) for custom visual logging
* - Max entry limit
*/
import TiBaseComponent from '../libs/ti-base-component'
/** Only use a template if you want to isolate the code and CSS */
const template = document.createElement('template')
template.innerHTML = /** @type {HTMLTemplateElement} */ /*html*/`
<style>
:host {
display: block; /* default is inline */
contain: content; /* performance boost */
}
.wrapper {
display: grid;
grid-template-columns: minmax(6ch, 1fr) minmax(75%, 5fr);
column-gap: 1rem;
row-gap: .3rem;
align-content: start;
height: 22em;
overflow: auto;
resize: vertical;
font-family: Consolas, "ui-monospace", "Lucida Console", monospace;
font-size: smaller;
/* white-space: pre; */
background-color:black;
border: 1px solid silver;
}
.meta {
/* grid-area: meta; */
border-right: 1px solid silver;
font-size: larger;
/* background-color:black; */
/* white-space: pre; */
}
.data {
/* grid-area: content; */
/* color:white; */
/* display:block; */
/* background-color:black; */
/* padding:5px 10px; */
/* font-family: Consolas, "ui-monospace", "Lucida Console", monospace; */
/* font-size: smaller; */
/* white-space: pre; */
/* width: 99%; */
/* height: 22em; */
/* overflow: auto; */
/* resize: both; */
}
.key {color:#ffbf35}
.string {color:#5dff39;}
.number {color:#70aeff;}
.boolean {color:#b993ff;}
.null {color:#93ffe4;}
.undefined {color:#ff93c9;}
.log {color: hsl(120 100 77);}
.info {color: hsl(212 100 77);}
.warn {color: orange;}
.error {color: red;}
.data-info {background-color: hsl(192 100 42 /0.3);}
.data-warn {background-color: hsl(39 100 42 /0.3);}
.data-error {background-color: hsl(0 100 42 /0.3);}
</style>
<div class="wrapper"></div>
`
/** Get the file, line and column number of the calling function
* Used to capture the originating file and line number of the console output for output because the process otherwise changes the file/line number.
* @returns {Array|null} An array of arrays containing the file, line and column number or null if parsing fails
*/
function getStackTrace() {
return new Error().stack.split('\n').slice(1).map(line => { // eslint-disable-line @stylistic/newline-per-chained-call
// Ignore any line that contains the text "dist/visible-console." ie this file
if (line.includes('dist/visible-console.')) return null
/** Only return the file, line and column number @type {Array<string|number>} */
const match = line.match(/\/([^\/:]+):(\d+):(\d+)\)?$/)
if (match) {
try { match[2] = Number(match[2]) } catch (e) { /* empty */ }
try { match[3] = Number(match[3]) } catch (e) { /* empty */ }
return [match[1], match[2], match[3]]
}
// Return null if parsing fails
return null
// Remove null entries if parsing fails
}).filter(Boolean) // eslint-disable-line @stylistic/newline-per-chained-call
}
/**
* @namespace Beta
*/
/**
* @class
* @augments TiBaseComponent
* @description A zero dependency web component that will display JavaScript console output on-page.
*
* @element component-template
* @memberOf Beta
*
* @example
* <visible-console></visible-console>
* METHODS FROM BASE:
* @function config Update runtime configuration, return complete config
* @function createShadowSelectors Creates the jQuery-like $ and $$ methods
* @function deepAssign Object deep merger
* @function doInheritStyles If requested, add link to an external style sheet
* @function ensureId Adds a unique ID to the tag if no ID defined.
* @function _uibMsgHandler Not yet in use
* @function _event (name,data) Standardised custom event dispatcher
* OTHER METHODS:
* @function redirectConsole Capture console.xxxx and write to the div
* @function newLog Creates a new HTML log entry
* @function checkType Find out the input JavaScript var type
* @function createHTMLVisualizer Creates an HTML visualisation of the input
* CUSTOM EVENTS:
* "visible-console:connected" - When an instance of the component is attached to the DOM. `evt.details` contains the details of the element.
* "component-template:ready" - Alias for connected. The instance can handle property & attribute changes
* "visible-console:disconnected" - When an instance of the component is removed from the DOM. `evt.details` contains the details of the element.
* "visible-console:attribChanged" - When a watched attribute changes. `evt.details` contains the details of the change.
* NOTE that listeners can be attached either to the `document` or to the specific element instance.
* Standard watched attributes (common across all my components):
* @property {string|boolean} inherit-style - Optional. Load external styles into component (only useful if using template). If present but empty, will default to './index.css'. Optionally give a URL to load.
* @property {string} name - Optional. HTML name attribute. Included in output _meta prop.
* Other watched attributes:
* None
* PROPS FROM BASE: (see TiBaseComponent)
* OTHER STANDARD PROPS:
* @property {string} componentVersion Static. The component version string (date updated). Also has a getter that returns component and base version strings.
* Other props:
* @property {object} colors The colors to use for different console output types
* @property {object} bgColors The background colors to use for different console output types
* @property {object} icons The icons to use for different console output types
* @slot No slot
* See https://github.com/runem/web-component-analyzer?tab=readme-ov-file#-how-to-document-your-components-using-jsdoc
*/
class VisibleConsole extends TiBaseComponent {
/** Component version */
static componentVersion = '2025-02-25'
/** Makes HTML attribute change watched
* @returns {Array<string>} List of all of the html attribs (props) listened to
*/
static get observedAttributes() {
return [
// Standard watched attributes:
'inherit-style', 'name',
// Other watched attributes:
]
}
// Keep a COPY of the original console so we can still use it if we want
nativeConsole = { ...console, }
/** Runtime configuration settings */
opts = {}
colors = {
'log': 'green',
'error': 'red',
'warn': 'orange',
}
bgColors = {
'info': 'hsl(92, 100, 50, 0.3)',
'warn': 'hsl(39, 100, 50, 0.3)',
'error': 'hsl(0, 100, 50, 0.3)',
}
icons = {
'log': '> ',
'info': 'ℹ️ ',
'debug': '🪲 ',
'trace': '👓 ',
'warn': '⚠️ ',
'error': '⛔ ',
}
//#endregion --- Class Properties ---
/** NB: Attributes not available here - use connectedCallback to reference */
constructor() {
super()
this.attachShadow({ mode: 'open', delegatesFocus: true, })
// Only append the template if code and style isolation is needed
.append(template.content.cloneNode(true))
// jQuery-like selectors but for the shadow. NB: Returns are STATIC not dynamic lists
this.createShadowSelectors() // in base class
// @ts-ignore
this.wrapper = this.shadowRoot.querySelector('.wrapper')
}
/** Runs when an instance is added to the DOM */
connectedCallback() {
// Make sure instance has an ID. Create an id from name or calculation if needed
this.ensureId() // in base class
// Apply parent styles from a stylesheet if required - only required if using an applied template
this.doInheritStyles() // in base class
this.redirectConsole()
// Keep at end. Let everyone know that a new instance of the component has been connected
this._event('connected')
this._event('ready')
}
/** Runs when an instance is removed from the DOM */
disconnectedCallback() {
// Keep at end. Let everyone know that an instance of the component has been disconnected
this._event('disconnected')
}
/** Handle watched attributes
* NOTE: On initial startup, this is called for each watched attrib set in HTML - BEFORE connectedCallback is called.
* Attribute values can only ever be strings
* @param {string} attrib The name of the attribute that is changing
* @param {string} oldVal The old value of the attribute
* @param {string} newVal The new value of the attribute
*/
attributeChangedCallback(attrib, oldVal, newVal) {
// Don't bother if the new value same as old
if ( oldVal === newVal ) return
// Create a property from the value - WARN: Be careful with name clashes
this[attrib] = newVal
// Add other dynamic attribute processing here.
// If attribute processing doesn't need to be dynamic, process in connectedCallback as that happens earlier in the lifecycle
// Keep at end. Let everyone know that an attribute has changed for this instance of the component
this._event('attribChanged', { attribute: attrib, newVal: newVal, oldVal: oldVal, })
}
/** Capture console.xxxx and write to the div
* NB: Cannot use bind here and so console output will have the wrong file/line number
*/
redirectConsole() {
Object.keys(this.icons).forEach( method => {
console[method] = (...args) => {
// capture the originating file and line number
// this.nativeConsole.log(getStackTrace())
// Call the original console.log - apply adds this fn to the callback trace 😞
this.nativeConsole[method].apply(console[method], args)
// Add the log to the visible console
this.newLog(method, args)
}
})
}
/** Creates a new HTML log entry
* @param {string} type The log type
* @param {*} args The arguments to log
*/
newLog(type, args) {
const icon = this.icons?.[type] || ''
// TODO Use template and clone
// Create a new line in the output div
const newMeta = document.createElement('div')
newMeta.className = `meta ${type}`
newMeta.innerHTML = `${icon} ${type}`
this.wrapper?.appendChild(newMeta)
// Convert args to a string
const message = args.map(arg => {
return this.createHTMLVisualizer(arg)
})
const newLog = document.createElement('div')
newLog.className = `data data-${type}`
message.forEach( el => {
newLog.appendChild(el)
} )
this.wrapper?.appendChild(newLog)
// Scroll to the bottom
// @ts-ignore
this.wrapper.scrollTop = this.wrapper?.scrollHeight
}
/** Find out the input JavaScript var type
* @param {*} input The JavaScript var to type
* @returns {string} The input type
*/
checkType(input) {
if (input === null) {
return 'null'
} else if (Array.isArray(input)) {
return 'array'
} else if (typeof input === 'object') {
return 'object'
}
return typeof input
}
/** Creates an HTML visualisation of the input
* @param {*} input Input data value to visualise
* @returns {HTMLDivElement} DIV element containing the visualisation
*/
createHTMLVisualizer(input) {
const container = document.createElement('div')
// container.style.backgroundColor = 'black'
// container.style.color = 'white'
// container.style.fontFamily = 'monospace'
// container.style.padding = '10px'
// container.style.whiteSpace = 'pre-wrap'
const renderValue = (value, level = 0) => {
const wrapper = document.createElement('div')
wrapper.style.marginLeft = `${level * 10}px`
if (level > 10) formatPrimitive(value) // not ideal but it doesn't crash
else if (Array.isArray(value)) {
wrapper.appendChild(renderCollapsible('Array', value, level, '[', ']'))
} else if (value && typeof value === 'object') {
wrapper.appendChild(renderCollapsible('Object', value, level, '{', '}'))
} else {
wrapper.appendChild(formatPrimitive(value))
}
return wrapper
}
const renderCollapsible = (label, value, level, openSymbol, closeSymbol) => {
const container = document.createElement('div')
const header = document.createElement('span')
const content = document.createElement('div')
const icon = document.createElement('span')
icon.textContent = '▶' // Initial icon for collapsed state
header.textContent = ` ${openSymbol}${getCollapsedSummary(value)}${closeSymbol}`
header.style.cursor = 'pointer'
// header.style.color = 'lightblue'
icon.style.cursor = 'pointer'
// icon.style.color = 'lightblue'
icon.addEventListener('click', () => {
const isCollapsed = content.style.display === 'none'
content.style.display = isCollapsed ? 'block' : 'none'
icon.textContent = isCollapsed ? '▼' : '▶'
header.textContent = isCollapsed ? ` ${openSymbol}` : ` ${openSymbol}${getCollapsedSummary(value)}${closeSymbol}`
})
header.addEventListener('click', () => {
const isCollapsed = content.style.display === 'none'
content.style.display = isCollapsed ? 'block' : 'none'
icon.textContent = isCollapsed ? '▼' : '▶'
header.textContent = isCollapsed ? ` ${openSymbol}` : ` ${openSymbol}${getCollapsedSummary(value)}${closeSymbol}`
})
content.style.display = 'none'
content.style.marginLeft = '20px'
for (const key in value) {
const line = document.createElement('div')
line.style.marginLeft = `${level * 10}px`
const keySpan = document.createElement('span')
keySpan.style.color = 'orange'
keySpan.textContent = `${key}: `
const valueSpan = renderValue(value[key], level++)
line.appendChild(keySpan)
line.appendChild(valueSpan)
content.appendChild(line)
}
const closeSymbolSpan = document.createElement('span')
closeSymbolSpan.textContent = ` ${closeSymbol}`
closeSymbolSpan.style.color = 'lightblue'
container.appendChild(icon)
container.appendChild(header)
container.appendChild(content)
container.appendChild(closeSymbolSpan)
return container
}
const getCollapsedSummary = (value) => {
let summary = ''
if (Array.isArray(value)) {
summary = value.map(v => getPrimitiveSummary(v)).join(', ')
} else if (typeof value === 'object') {
summary = Object.keys(value).map(key => `${key}: ${getPrimitiveSummary(value[key])}`).join(', ') // eslint-disable-line @stylistic/newline-per-chained-call
}
if (summary.length > 30) {
summary = summary.slice(0, 30) + '...'
}
return summary
}
const getPrimitiveSummary = (value) => {
if (typeof value === 'string') {
return `"${value}"`
} else if (typeof value === 'number') {
return value
} else if (typeof value === 'boolean') {
return value
} else if (value === null) {
return 'null'
} else if (typeof value === 'undefined') {
return 'undefined'
} else if (typeof value === 'bigint') {
return `${value}n`
} else if (typeof value === 'function') {
return '[Function]'
} else if (Array.isArray(value)) {
return '[Array]'
} else if (typeof value === 'object') {
return '[Object]'
}
}
const formatPrimitive = (value) => {
const span = document.createElement('span')
if (typeof value === 'string') {
span.style.color = 'lightgreen'
span.textContent = `"${value}"`
} else if (typeof value === 'number') {
span.style.color = 'lightcoral'
// @ts-ignore
span.textContent = value
} else if (typeof value === 'boolean') {
span.style.color = 'lightyellow'
// @ts-ignore
span.textContent = value
} else if (value === null) {
span.style.color = 'gray'
span.textContent = 'null'
} else if (typeof value === 'undefined') {
span.style.color = 'gray'
span.textContent = 'undefined'
} else if (typeof value === 'bigint') {
span.style.color = 'lightcoral'
span.textContent = `${value}n`
} else if (typeof value === 'function') {
span.style.color = 'violet'
span.textContent = '[Function]'
}
return span
}
container.appendChild(renderValue(input))
return container
}
}
// Make the class the default export so it can be used elsewhere
export default VisibleConsole
/** Self register the class to global
* Enables new data to be dynamically added via JS
* and lets any static methods be called
*/
window['VisibleConsole'] = VisibleConsole
// Add the class as a new Custom Element to the window object
customElements.define('visible-console', VisibleConsole)