Sitevision inspector

With this bookmarklet, you can easily read properties, nodes, headless data and search index from the currently viewed page. Useful when developing and debugging.

Created
2023-04-24
Updated
2024-10-29
Bookmarklet size
20.5 kB

Drag this link to your bookmarks to save it.

Sitevision inspector

Bookmarklet code

sitevision-inspector

import { Dialog, DialogView } from './Dialog';
import Formatters from './Formatters';
import NodeTypes from './NodeTypes';
import sitevisionApi from '../../_lib/shared/sitevision-api';
import Events from '../../_lib/shared/Events';

((window) => {
const { pageId: nodeId, siteId } = 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;
}

let __siteName;

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' });
})
.onBuildIndex(function (data) {
return [ ...Object.entries(data) ].map(([ property, value ]) => property + ' ' + value);
})
.formatter(new Formatters.TableFormatter({ caption: (data) => `Properties for ${data.articleName || data.displayName}` })),

// 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 });
})
.onBuildIndex(function (data) {
return data.map(node => node.name);
})
.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()),

new DialogView('Search index')
.onFetchData(async function () {
if (!__siteName) {
const siteProps = await sitevisionApi({ nodeId: siteId, version: this.dialog.version, apiMethod: 'properties' });

if (siteProps?.displayName) {
__siteName = siteProps.displayName;
} else {
throw 'Could not read site name.';
}
}

const searchResult = await sitevisionApi({ path: 'Index Repository/Online', siteName: __siteName, version: this.dialog.version, apiMethod: 'search', options: {
query: `+id:${nodeId}`,
limit: 1,
fields: ['*', 'nodeid'],
} });

if (searchResult.length === 0) {
throw 'No indexed data found for current page.';
}

return Object.entries(searchResult[0]).reduce((acc, [key, value]) => {
if (Array.isArray(value)) {
for (const v of value) {
acc.push([key, v]);
}
} else {
acc.push([key, value]);
}

return acc;
}, []);
})
.onBuildIndex(function (data) {
if (!Array.isArray(data)) {
return null;
}

return data.map(([ property, value ]) => property + ' ' + value);
})
.formatter(new Formatters.TableFormatter({ headings: [ 'Field', 'Value' ] })),
],
})
.init()
.toggle();
})(window)

NodeTypes.js

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,
};

Icons.js

// 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,
};

Formatters.js

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.caption = '';
this.headings = ['Property', 'Value'];
this.data = null;

this.applyOpts(opts);
}

applyOpts (opts) {
if (opts.emptyText) {
this.emptyText = opts.emptyText;
}

if (opts.caption) {
this.caption = opts.caption;
}

if (opts.headings) {
this.headings = opts.headings;
}
}

setData (data) {
this.data = data;
return this;
}

silentStringify (mixed) {
try {
return JSON.stringify(mixed, null, 2);
} catch (_) {}

return '';
}

sanitize (str) {
const dict = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;',
// 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;
}

const rows = Array.isArray(data) ? data : Object.entries(data);
const caption = typeof this.caption === 'function' ? this.caption.call(null, data) : this.caption;
const [ keyHeading, valueHeading ] = this.headings;

return (
`<table class="env-table env-table--zebra env-table--small env-w--100">
<caption class="env-assistive-text">
${s(caption)}</caption>
<thead>
<tr><th>
${keyHeading}</th><th>${valueHeading}</th></tr>
</thead>
<tbody>
${rows.map(([ key, value ]) =>
`<tr data-filter-item><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" data-filter-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 yet have utility class for justify-content: start;
// env-d--flex does not override display of env-button in older SV versions so we need to inline display flex.
`<button class="env-nav__link env-button env-button--link env-w--100" style="justify-content: start; display: flex;" 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,
};

Events.js

const CLICK = 'click';
const CHANGE = 'change';
const INPUT = 'input';
const KEYDOWN = 'keydown';

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),
onInput: (el, cb) => add(el, INPUT, cb),
onKeydown: (el, cb) => add(el, KEYDOWN, cb),
};

Dialog.js

import { Formatter } from './Formatters';
import Events from '../../_lib/shared/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.sfid = dialogId + '-sf'; // Search filter
this.siid = dialogId + '-si'; // Search input

this.views.forEach((view) => view.setDialog(this));
}

el (id) {
return DOCUMENT.getElementById(id);
}

init () {
const dialogId = this.id;
const titleId = this.tid;
const searchInputId = this.siid;

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.sfid}" class="env-form-element">
<label for="
${searchInputId}" class="env-form-element__label">Filter query</label>
<div class="env-form-element__control">
<input type="search" class="env-form-input env-form-input--search" id="
${searchInputId}" />
</div>
</div>

<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);
}
};

const filterInputCallback = (event) => {
this.applyFilter(event.target.value);
};

const filterKeydownCallback = (event) => {
// Prevent Escape from hiding modal if clearing filter input by escape key.
// When the filter input is empty, act as normal.
if (event.key === 'Escape' && !!event.target.value) {
event.stopImmediatePropagation();
}
};

Events.onClick(this.el(this.bgid), buttonsCallback);
Events.onInput(this.el(this.siid), filterInputCallback);
Events.onKeydown(this.el(this.siid), filterKeydownCallback);
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);
}

applyFilter (searchQuery) {
const view = this.views[this.current];
const index = view.getIndex();

if (!index) {
return;
}

const query = (searchQuery ?? this.el(this.siid).value).toLowerCase();

this.el(this.cid).querySelectorAll('[data-filter-item]').forEach((el, i) => {
el.style.display = !!query && !index[i].includes(query) ? 'none' : '';
});
}

render () {
const view = this.views[this.current];
const renderedView = view.render.call(view);

if (renderedView) {
this.updateContent(renderedView);
this.applyFilter();
}

this.el(this.sfid).style.display = view.getIndex() ? '' : 'none';
}

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._onUpdateIndex = null;
this._data = null;
this._index = null;
this.config = config;
}

setDialog (dialog) {
this.dialog = dialog;
return this;
}

setData (data) {
this._data = data;
this.buildIndex(data);
}

getIndex () {
return this._index;
}

buildIndex (data) {
if (typeof this._buildIndex === 'function') {
this._index = this._buildIndex.call(this, data)?.map(s => s.toLowerCase());
}
}

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 () {
try {
if (typeof this._attach === 'function') {
await this._attach.call(this);
}
} catch (e) {
this.dialog.renderError(String(e));
}
}

async detach () {
try {
if (typeof this._detach === 'function') {
await this._detach.call(this);
}
} catch (e) {
this.dialog.renderError(String(e));
}
}

render () {
if (this._formatter instanceof Formatter && this._data) {
return this._formatter.setData(this._data).render();
}

return '';
}

formatter (formatter) {
this._formatter = formatter;
return this;
}

onBuildIndex (cb) {
this._buildIndex = cb;
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;

sitevision-api.js

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 {string} options.path The path to perform the api request on.
* @param {string} options.siteName The site's name. Required when using path.
* @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, path, siteName, version = ONLINE, apiMethod = 'nodes', options = {}, origin = window.location.origin }) {
if (!nodeId && !(path && siteName)) {
throw 'Api needs either a nodeId or a path.';
}

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 || (siteName + '/' + path.replace(/^\/|\/$/, ''))}/${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;
}