Sitevision inspector
With this bookmarklet, you can easily read properties, nodes and headless data from the currently viewed page. Useful when developing and debugging.
With this bookmarklet, you can easily read properties, nodes and headless data from the currently viewed page. Useful when developing and debugging.
Drag this link to your bookmarks to save it.
Sitevision inspectorimport { Dialog, DialogView } from './Dialog';
import Formatters from './Formatters';
import NodeTypes from './NodeTypes';
import sitevisionApi from './sitevision-api';
import Events from './Events';
((window) => {
const { pageId: nodeId } = window.sv?.PageContext || {};
// Bail early if envision is not found.
if (!window.envision) {
console.warn('Envision not found. Exiting since most likely not Sitevision.');
return;
}
new Dialog({
dialogId: 'sitevision-inspector-dialog',
views: [
// Properties view
new DialogView('Properties')
.onFetchData(async function () {
return await sitevisionApi({ nodeId, version: this.dialog.version, apiMethod: 'properties' });
})
.formatter(new Formatters.TableFormatter()),
// Nodes view
new DialogView('Nodes', {
breadcrumbs: [],
})
.onFetchData(async function (id = nodeId) {
const options = {
includes: Object.values(NodeTypes),
properties: [ 'URI' ]
};
return await sitevisionApi({ nodeId: id, version: this.dialog.version, apiMethod: 'nodes', options });
})
.formatter(new Formatters.ListFormatter({ emptyText: 'No nodes found' }))
.onAttach(async function () {
const dialog = this.dialog;
const breadcrumbs = this.config.breadcrumbs = [ nodeId ];
this.onClick = async (event) => {
const target = event.target;
const BACK = 'back';
if (!this.isLoading && /^button$/i.test(target.tagName)) {
let id = target.dataset.nodeId;
if (id === BACK) {
breadcrumbs.pop();
} else {
breadcrumbs.push(id);
}
id = breadcrumbs[breadcrumbs.length - 1];
dialog.setLoading(true);
const data = await this.fetchData(id);
if (id !== nodeId) {
data.unshift({
type: BACK,
id: BACK,
name: 'Go back',
});
}
this.setData(data);
dialog.render();
dialog.setLoading(false);
}
};
Events.onClick(dialog.el(dialog.cid), this.onClick);
})
.onDetach(async function () {
const dialog = this.dialog;
Events.offClick(dialog.el(dialog.cid), this.onClick);
}),
// Headless view
new DialogView('Headless')
.onFetchData(async function () {
return await sitevisionApi({ nodeId, version: this.dialog.version, apiMethod: 'headless' });
})
.formatter(new Formatters.JsonFormatter()),
],
})
.init()
.toggle();
})(window)
import { Formatter } from './Formatters';
import Events from './Events';
const CLASS_BASE = 'env-modal-dialog';
const ACTIVE_CLASS = 'env-button--primary';
const SPINNER_CLASS = 'env-spinner-bounce';
const DOCUMENT = window.document;
const ENVISION = window.envision;
/**
* Creates the HTML markup for an envision dialog.
*
* @param {object} options An object literal with options.
* @param {string} options.dialogId A unique html ID for the dialog.
* @param {string} [options.views=[]]
* @returns {string}
*/
export class Dialog {
constructor ({ dialogId, views = [] }) {
this.id = dialogId;
this.views = views;
this.current = 0;
this.isLoading = false;
this.version = 1;
this.tid = dialogId + '-t'; // title
this.cid = dialogId + '-c'; // content
this.bid = dialogId + '-b'; // button id prefix
this.bgid = dialogId + '-bg'; // button group
this.fid = dialogId + '-f'; // footer
this.lid = dialogId + '-l'; // loader
this.views.forEach((view) => view.setDialog(this));
}
el (id) {
return DOCUMENT.getElementById(id);
}
init () {
const dialogId = this.id;
const titleId = this.tid;
if (this.el(dialogId)) {
return this;
}
// Insert dialog into end of body and show dialog with envision.
DOCUMENT.body.insertAdjacentHTML('beforeend', (
`<div id="${dialogId}" class="${CLASS_BASE} ${CLASS_BASE}--inner-scroll" style="z-index:99999" role="dialog" aria-modal="true" aria-labelledby="${titleId}" aria-hidden="true" tabindex="-1">
<div class="${CLASS_BASE}__dialog ${CLASS_BASE}__dialog--large">
<section class="${CLASS_BASE}__content">
<header class="${CLASS_BASE}__header">
<h5 id="${titleId}" class="env-text env-d--flex env-flex--align-items-center env-flex--justify-content-between ${CLASS_BASE}__header__title">
Sitevision inspector
<div id="${this.lid}" class="${SPINNER_CLASS} ${SPINNER_CLASS}--hide ${SPINNER_CLASS}--fade-in" data-delay="medium">
${[1,2,3].map(i => `<div class="env-bounce${i}"></div>`).join('')}
</div>
</h5>
<div id="${this.bgid}" class="env-button-group env-m-top--small">
${this.views.map((view, i) => `<button id="${this.bid + i}" type="button" class="env-button env-flex__item--grow-1" data-view-index="${i}">${view.name}</button>`).join('')}
</div>
</header>
<div class="${CLASS_BASE}__body">
<div id="${this.cid}">View properties, nodes and headless data with the buttons above.</div>
</div>
<footer id="${this.fid}" class="${CLASS_BASE}__footer env-d--flex env-flex--justify-content-between">
<fieldset class="env-form-element__control env-d--flex env-flex--align-items-center" style="gap: var(--env-spacing-medium);">
<legend class="env-form-element__label env-m-bottom--0" style="float: left; font-weight: 700;">Version:</legend>
<label class="env-radio env-m-bottom--0"><input type="radio" name="version" value="1" checked>Online (Public)</label>
<label class="env-radio env-m-bottom--0"><input type="radio" name="version" value="0">Offline (Edit)</label>
</fieldset>
<button type="button" data-modal-dialog-dismiss class="env-button">Close</button>
</footer>
</section>
</div>
</div>`
));
const buttonsCallback = async (event) => {
if (!this.isLoading && /^button$/i.test(event.target.tagName)) {
this.setLoading(true);
this.el(this.bid + this.current).classList.remove(ACTIVE_CLASS);
await this.detach();
this.current = parseInt(event.target.dataset.viewIndex, 10);
await this.fetch();
this.render();
await this.attach();
this.el(this.bid + this.current).classList.add(ACTIVE_CLASS);
this.setLoading(false);
}
};
Events.onClick(this.el(this.bgid), buttonsCallback);
Events.onChange(this.el(this.fid), async (event) => {
this.version = parseInt(event.target.value, 10);
buttonsCallback({
target: this.el(this.bid + this.current)
});
});
return this;
}
updateContent (html) {
this.el(this.cid).innerHTML = html;
}
async attach () {
const view = this.views[this.current];
await view.attach.call(view);
}
async detach () {
const view = this.views[this.current];
await view.detach.call(view);
}
setLoading (isLoading) {
this.isLoading = !!isLoading;
this.el(this.lid).classList[this.isLoading ? 'remove' : 'add'](`${SPINNER_CLASS}--hide`);
}
async fetch () {
const view = this.views[this.current];
const data = await view.fetchData.call(view);
view.setData(data);
}
render () {
const view = this.views[this.current];
const renderedView = view.render.call(view);
if (renderedView) {
this.updateContent(renderedView);
}
}
renderError (errorMessage) {
this.updateContent(
`<div class="env-alert env-alert--danger">${errorMessage}</div>`
);
}
toggle (cb) {
ENVISION.dialog('#' + this.id, 'toggle').then(cb);
return this;
}
};
export class DialogView {
constructor (name, config = {}) {
this.name = name;
this.dialog = null;
this._formatter = null;
this._fetch = null;
this._attach = null;
this._detach = null;
this._data = null;
this.config = config;
}
setDialog (dialog) {
this.dialog = dialog;
return this;
}
setData (data) {
this._data = data;
}
async fetchData (...args) {
try {
if (typeof this._fetch === 'function') {
return await this._fetch.call(this, ...args);
}
} catch (e) {
this.dialog.renderError(String(e));
}
return false;
}
async attach () {
if (typeof this._attach === 'function') {
await this._attach.call(this);
}
}
async detach () {
if (typeof this._detach === 'function') {
await this._detach.call(this);
}
}
render () {
if (this._formatter instanceof Formatter && this._data) {
return this._formatter.setData(this._data).render();
}
return '';
}
formatter (formatter) {
this._formatter = formatter;
return this;
}
onFetchData (cb) {
this._fetch = cb;
return this;
}
onAttach (cb) {
this._attach = cb;
return this;
}
onDetach (cb) {
this._detach = cb;
return this;
}
};
export default Dialog;
const CLICK = 'click';
const CHANGE = 'change';
const add = (el, event, cb) => el.addEventListener(event, cb);
const remove = (el, event, cb) => el.removeEventListener(event, cb);
export default {
onClick: (el, cb) => add(el, CLICK, cb),
offClick: (el, cb) => remove(el, CLICK, cb),
onChange: (el, cb) => add(el, CHANGE, cb),
};
import NodeTypes from './NodeTypes';
import Icons from './Icons';
const ICONS_DICT = {
[NodeTypes.ARCHIVE]: Icons.ARCHIVE,
[NodeTypes.FOLDER]: Icons.FOLDER,
[NodeTypes.PAGE]: Icons.PAGE,
[NodeTypes.ARTICLE]: Icons.ARTICLE,
[NodeTypes.LINK]: Icons.LINK,
[NodeTypes.GROUP_PAGE]: Icons.GROUP_PAGE,
[NodeTypes.GROUP_FOLDER]: Icons.GROUP_FOLDER,
'back': Icons.BACK,
};
export class Formatter {
constructor (opts = {}) {
this.emptyText = 'No data found',
this.data = null;
this.applyOpts(opts);
}
applyOpts (opts) {
if (opts.emptyText) {
this.emptyText = opts.emptyText;
}
}
setData (data) {
this.data = data;
return this;
}
silentStringify (mixed) {
try {
return JSON.stringify(mixed, null, 2);
} catch (_) {}
return '';
}
sanitize (str) {
const dict = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'/': '/',
// Fix for characters in Unicode Private Use Area
'\uF03A': ':',
'\uF02F': '/',
};
return str ? String(str).replace(/[&<>"'/\uF03A\uF02F]/g, (match) => dict[match]) : '';
}
render () {
return '';
}
}
export class TableFormatter extends Formatter {
render () {
const data = this.data;
const dataStr = this.silentStringify(data);
const s = this.sanitize;
if (!dataStr || dataStr === '{}') {
return this.emptyText;
}
return (
`<table class="env-table env-table--zebra env-table--small env-w--100">
<caption class="env-assistive-text">Properties for ${s(data.articleName || data.displayName)}</caption>
<thead>
<tr><th>Property</th><th>Value</th></tr>
</thead>
<tbody>
${Object.entries(data).map(([ key, value ]) =>
`<tr><td style="white-space:nowrap">${s(key)}</td><td>${s(value)}</td></tr>`
).join('')}
</tbody>
</table>`
);
}
}
export class ListFormatter extends Formatter {
icon (type) {
return ICONS_DICT[type] ? String(ICONS_DICT[type]).replace('<svg ', '<svg class="env-icon env-icon--small env-m-right--small" ') : '';
}
render () {
const data = this.data;
const s = this.sanitize;
const i = this.icon;
if (!Array.isArray(data) || data.length === 0) {
return this.emptyText;
}
return (
`<ul class="env-nav env-nav--sidenav">
${data.map(item =>
`<li class="env-nav__item">
${[NodeTypes.PAGE, NodeTypes.ARTICLE, NodeTypes.LINK, NodeTypes.GROUP_PAGE].includes(item.type) ?
`<a class="env-nav__link env-d--flex" href="${s(item.properties.URI)}">${i(item.type)}${s(item.name)}</a>`
:
// Envision does not have utility class for justify-content: start;
`<button class="env-nav__link env-button env-button--link env-d--flex env-w--100" style="justify-content: start;" data-node-id="${s(item.id)}">${i(item.type)}${s(item.name)}</button>`
}
</li>`
).join('')}
</ul>`
);
}
}
export class JsonFormatter extends Formatter {
render () {
const formattedData = this.silentStringify(this.data);
if (!formattedData || formattedData === '{}') {
return this.emptyText;
}
return (
`<pre style="background-color:var(--env-ui-color-brand-10);color:var(--env-ui-color-brand-10-contrast);overflow:scroll;padding:1em;"><code>${formattedData}</code></pre>`
);
}
}
export default {
TableFormatter,
ListFormatter,
JsonFormatter,
};
// Icons from Material Design. Generated by IcoMoon.io
const ARTICLE = `<svg width="24" height="24" viewBox="0 0 24 24"><path d="M18.984 3h-13.969q-0.844 0-1.43 0.586t-0.586 1.43v13.969q0 0.844 0.586 1.43t1.43 0.586h13.969q0.844 0 1.43-0.586t0.586-1.43v-13.969q0-0.844-0.586-1.43t-1.43-0.586zM14.016 17.016h-7.031v-2.016h7.031v2.016zM17.016 12.984h-10.031v-1.969h10.031v1.969zM17.016 9h-10.031v-2.016h10.031v2.016z"></path></svg>`;
const BACK = `<svg width="24" height="24" viewBox="0 0 24 24"><path d="M8.016 13.031l3.984-4.031 3.984 4.031-1.406 1.406-1.594-1.594v4.172h-1.969v-4.172l-1.594 1.594zM20.016 18v-9.984h-16.031v9.984h16.031zM20.016 6q0.797 0 1.383 0.609t0.586 1.406v9.984q0 0.797-0.586 1.406t-1.383 0.609h-16.031q-0.797 0-1.383-0.609t-0.586-1.406v-12q0-0.797 0.586-1.406t1.383-0.609h6l2.016 2.016h8.016z"></path></svg>`;
const FOLDER = `<svg width="24" height="24" viewBox="0 0 24 24"><path d="M9.984 3.984l2.016 2.016h8.016q0.797 0 1.383 0.609t0.586 1.406v9.984q0 0.797-0.586 1.406t-1.383 0.609h-16.031q-0.797 0-1.383-0.609t-0.586-1.406v-12q0-0.797 0.586-1.406t1.383-0.609h6z"></path></svg>`;
const PAGE = `<svg width="24" height="24" viewBox="0 0 24 24"><path d="M12.984 9h5.531l-5.531-5.484v5.484zM6 2.016h8.016l6 6v12q0 0.797-0.609 1.383t-1.406 0.586h-12q-0.797 0-1.406-0.586t-0.609-1.383l0.047-16.031q0-0.797 0.586-1.383t1.383-0.586z"></path></svg>`;
const ARCHIVE = `<svg width="24" height="24" viewBox="0 0 24 24"><path d="M15 15.984h6v3q0 0.797-0.609 1.406t-1.406 0.609h-13.969q-0.797 0-1.406-0.609t-0.609-1.406v-3h6q0 1.219 0.891 2.109t2.109 0.891 2.109-0.891 0.891-2.109zM18.984 9v-3.984h-13.969v3.984h3.984q0 1.219 0.891 2.109t2.109 0.891 2.109-0.891 0.891-2.109h3.984zM18.984 3q0.797 0 1.406 0.609t0.609 1.406v6.984q0 0.797-0.609 1.406t-1.406 0.609h-13.969q-0.797 0-1.406-0.609t-0.609-1.406v-6.984q0-0.797 0.609-1.406t1.406-0.609h13.969z"></path></svg>`;
const LINK = `<svg width="24" height="24" viewBox="0 0 24 24"><path d="M17.016 6.984q2.063 0 3.516 1.477t1.453 3.539-1.453 3.539-3.516 1.477h-4.031v-1.922h4.031q1.266 0 2.18-0.914t0.914-2.18-0.914-2.18-2.18-0.914h-4.031v-1.922h4.031zM8.016 12.984v-1.969h7.969v1.969h-7.969zM3.891 12q0 1.266 0.914 2.18t2.18 0.914h4.031v1.922h-4.031q-2.063 0-3.516-1.477t-1.453-3.539 1.453-3.539 3.516-1.477h4.031v1.922h-4.031q-1.266 0-2.18 0.914t-0.914 2.18z"></path></svg>`;
const GROUP_PAGE = `<svg id="icon-contact_page" viewBox="0 0 24 24"><path d="M14.016 2.016h-8.016q-0.844 0-1.43 0.586t-0.586 1.383v16.031q0 0.797 0.586 1.383t1.43 0.586h12q0.844 0 1.43-0.586t0.586-1.383v-12zM12 9.984q0.844 0 1.43 0.586t0.586 1.43-0.586 1.43-1.43 0.586-1.43-0.586-0.586-1.43 0.586-1.43 1.43-0.586zM15.984 18h-7.969v-0.563q0-0.609 0.328-1.125t0.891-0.75q0.609-0.281 1.313-0.422t1.453-0.141 1.453 0.141 1.313 0.422q0.563 0.234 0.891 0.75t0.328 1.125v0.563z"></path></svg>`;
const GROUP_FOLDER = `<svg id="icon-folder_shared" viewBox="0 0 24 24"><path d="M18.984 17.016v-1.031q0-0.891-1.383-1.43t-2.602-0.539-2.602 0.539-1.383 1.43v1.031h7.969zM15 9q-0.797 0-1.406 0.609t-0.609 1.406 0.609 1.383 1.406 0.586 1.406-0.586 0.609-1.383-0.609-1.406-1.406-0.609zM20.016 6q0.797 0 1.383 0.609t0.586 1.406v9.984q0 0.797-0.586 1.406t-1.383 0.609h-16.031q-0.797 0-1.383-0.609t-0.586-1.406v-12q0-0.797 0.586-1.406t1.383-0.609h6l2.016 2.016h8.016z"></path></svg>`;
export default {
ARTICLE,
BACK,
FOLDER,
PAGE,
ARCHIVE,
LINK,
GROUP_PAGE,
GROUP_FOLDER,
};
export const PAGE = 'sv:page';
export const FOLDER = 'sv:folder';
export const ARCHIVE = 'sv:archive';
export const ARTICLE = 'sv:article';
export const LINK = 'sv:link';
export const GROUP_PAGE = 'sv:collaborationGroupPage';
export const GROUP_FOLDER = 'sv:collaborationGroupFolder';
export default {
PAGE,
FOLDER,
ARCHIVE,
ARTICLE,
LINK,
GROUP_PAGE,
GROUP_FOLDER,
};
export const OFFLINE = 0;
export const ONLINE = 1;
/**
* Performs a fetch request to the Sitevision Rest API.
*
* @param {object} options An object literal with options.
* @param {string} options.nodeId The node identifier to perform the api request on.
* @param {number} [options.version=ONLINE] The version to use.
* @param {string} [options.apiMethod='nodes'] The API method to call. (Usually one of: 'nodes', 'properties', 'contentNodes', 'headless')
* @param {object} [options.options={}] Options to be passed to the API method.
* @param {string} [options.origin=window.location.origin] The origin to send the request to.
* @returns {Response}
*/
export default async function sitevisionApi ({ nodeId, version = ONLINE, apiMethod = 'nodes', options = {}, origin = window.location.origin }) {
const apiParams = encodeURIComponent(JSON.stringify(options));
const fetchOpts = {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
};
const v = [OFFLINE, ONLINE].includes(version) ? version : ONLINE;
const url = `${origin}/rest-api/1/${v}/${nodeId}/${apiMethod}?format=json&json=${apiParams}`;
if (v === OFFLINE) {
fetchOpts.credentials = 'same-origin';
}
const response = await fetch(url, fetchOpts);
let data = {};
try {
data = await response.json();
} catch (_) {}
if (!response.ok) {
throw [
`Status ${response.status}`,
data?.description,
data?.message,
].filter(v => !!v).join(', ');
}
return data;
}