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.
With this bookmarklet, you can easily read properties, nodes, headless data and search index 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 '../../_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)
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 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,
};
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 = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'/': '/',
// 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,
};
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),
};
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;
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;
}