Sitevision index

This bookmarklet opens a modal with a search form, allowing you to query the available indexes on a Sitevision website. Results are displayed in a table, where each row represents a search hit. Clicking a row reveals all the indexed data for that specific entry.

Perfect for exploring and debugging indexed content on Sitevision websites.

Screenshot with the bookmarklet's modal in view. Screenshot with indexed data for a search hit.
Created
2025-01-08
Bookmarklet size
17.1 kB

Drag this link to your bookmarks to save it.

Sitevision index

Bookmarklet code

sitevision-index

import createElement from '../../_lib/shared/create-element';
import { getCache, getInstance, removeInstance } from '../../_lib/shared/Dialog';
import VTable from '../../_lib/shared/VTable';
import uid from '../../_lib/shared/uid';
import Events from '../../_lib/shared/Events';
import sitevisionApi from '../../_lib/shared/sitevision-api';
import toasts from '../../_lib/shared/toasts';

(async (window, document) => {
// Bail early if envision is not found.
if (!window.envision) {
console.warn('Envision not found. Exiting since most likely not Sitevision.');
return;
}

const SITEVISION_INDEX_DIALOG = 'sitevision-index-dialog';
const SITEVISION_INDEX_ROW_DIALOG = `${SITEVISION_INDEX_DIALOG}_row`;

let indexes;
if (!document.getElementById(SITEVISION_INDEX_DIALOG)) {
try {
indexes = await sitevisionApi({ nodeId: `${window.sv.PageContext.siteId}_indexRepository` });
} catch (e) {
toasts.publish({ message: String(e) });

return;
}
}

const instance = getInstance(SITEVISION_INDEX_DIALOG, { title: 'Sitevision index', size: 'large' });
let rowDialogInstance = getCache(SITEVISION_INDEX_ROW_DIALOG);

await instance.init(async (dialog) => {
const container = createElement('<div></div>');
const searchFieldId = uid();
const indexFieldId = uid();
const searchForm = createElement(
`<form class="env-form">
<div class="env-form__row">
<div class="env-form-element">
<label for="
${indexFieldId}" class="env-form-element__label">Index</label>
<div class="env-form-element__control">
<select class="env-form-input" id="
${indexFieldId}" name="index">
${indexes.map((idx) => `<option value="${idx.id}">${idx.name}</option>`).join('')}
</select>
</div>
</div>

<div class="env-form-element env-form-element--2">
<label for="
${searchFieldId}" class="env-form-element__label">Search query</label>
<div class="env-form-element__control env-form-input-group">
<input id="
${searchFieldId}" type="search" name="query" class="env-form-input env-form-input--search env-flex__item--length-1" placeholder="" />
<button type="submit" class="env-button env-button--primary">Search</button>
</div>
</div>
</div>
</form>
`

);

const info = createElement(`<p class="env-text env-m-bottom--small" style="display:none;"></p>`);

container.appendChild(searchForm);
container.appendChild(info);

const headers = { '$index': 'No.', 'name': 'Name' };
const resultsTable = new VTable(headers, [], {});

resultsTable.onRowClick((rowData, rowIndex) => {
const rowTitle = rowData.name || rowData.title;
rowDialogInstance = getInstance(SITEVISION_INDEX_DIALOG + '_row', { title: rowTitle, size: 'large', closeText: 'Go back' });
dialog.setDisabled(true);

const rows = Object.entries(rowData).reduce(function (acc, [ key, value ]) {
if (key[0] === '$') {
return acc;
}
if (Array.isArray(value)) {
for (let v of value) {
acc.push([key, v]);
}
} else {
acc.push([key, value]);
}
return acc;
}, []);

rowDialogInstance.init(`<table class="env-table env-table--small env-table--zebra env-w--100"><caption class="env-assistive-text">Indexed fields for: ${rowTitle}</caption><thead><th>Field</th><th>Value</th></thead><tbody>${rows.map(([ field, value ]) => `<tr><td style="max-width:28rem;overflow-wrap:break-word;white-space:normal;">${field}</td><td>${value}</td></tr>`).join('')}</tbody></table>`);
rowDialogInstance.onClosed((rowDialog) => {
dialog.setDisabled(false);
removeInstance(rowDialog.id);
rowDialogInstance = null;
});
rowDialogInstance.toggle();
});

resultsTable.renderTo(container);

dialog.context.vTable = resultsTable;
dialog.context.searchForm = searchForm;

resultsTable.el.style.display = 'none';

const searchField = searchForm.querySelector('[name="query"]');
const indexField = searchForm.querySelector('[name="index"]');
let slowQueryTimerId;

Events.onSubmit(searchForm, async (event) => {
event.preventDefault();

if (slowQueryTimerId) {
clearTimeout(slowQueryTimerId);
slowQueryTimerId = null;
}

slowQueryTimerId = setTimeout(() => dialog.setLoading(true), 300);

const query = searchField.value;
const index = indexField.value;
const [ result, err ] = await sitevisionApi({ nodeId: index, apiMethod: 'search', options: { query, limit: 500, fields: [ '*' ] }, returnError: true });
const hasResult = !err && result?.length > 0;

clearTimeout(slowQueryTimerId);
slowQueryTimerId = null;
dialog.setLoading(false);
dialog.renderError(err);

resultsTable.el.style.display = hasResult ? '' : 'none';
info.style.display = '';
info.innerHTML = hasResult ? `${result.length}${result.length === 500 ? ` (or more)` : ''} hits found.` : 'No search hits found.';

if (hasResult && !resultsTable.rowHeight) {
resultsTable.computeTableDimensions();
}

resultsTable.update(result);
});

Events.onKeydown(searchField, (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();
}
});

return container;
});

instance
.onClose(() => {
if (rowDialogInstance) {
rowDialogInstance.hide();
rowDialogInstance = null;
}
})
.onClosed((dialog) => {
if (rowDialogInstance) {
dialog.setDisabled(false);
}
})
.toggle((dialog) => {
dialog.context.vTable?.computeTableDimensions();
dialog.context.vTable?.renderRows(dialog.context.vTable.el.scrollTop, true);
});
})(window, window.document)

create-element.js

const DOCUMENT = window.document;
// const ENVISION = window.envision;

export default function createElement (markup) {
const template = DOCUMENT.createElement('template');
template.innerHTML = markup;

return template.content.firstElementChild;
}

Events.js

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

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),
onSubmit: (el, cb) => add(el, SUBMIT, cb),
};

Dialog.js

import Events from "./Events";

const CLASS_BASE = 'env-modal-dialog';
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.title
* @returns {string}
*/


export class Dialog {
constructor ({ dialogId, title, size, closeText = 'Close' }) {
this.id = dialogId;
this.isLoading = false;
this.isDisabled = false;
this.title = title;
this.size = size;
this.closeText = closeText;
this.context = {};

this.tid = dialogId + '-t'; // title
this.thid = dialogId + '-th'; // title holder
this.cid = dialogId + '-c'; // content
this.eid = dialogId + '-e'; // error
this.fid = dialogId + '-f'; // footer
this.lid = dialogId + '-l'; // loader
}

/**
* Shortcut for Document.getElementById() for improved minification.
*
* @param {string} id An identifier for an HTMLElement.
* @returns {(HTMLElement|null)}
*/

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

/**
* Initializes the dialog if it hasn't already been initialized.
*
* @param {(Function|String|HTMLElement)} initialContent
* @returns {Dialog} Returns self for chainability.
*/

async init (initialContent) {
const dialogId = this.id;
const titleId = this.tid;

if (this.el(dialogId)) {
return this;
}

let initialContentError;

try {
initialContent = typeof initialContent === 'function' ? await initialContent.call(null, this) : initialContent;
} catch (e) {
initialContentError = e;
}

const contentString = typeof initialContent === 'string' ? initialContent : '';
const sizeClass = this.size ? `${CLASS_BASE}__dialog--${this.size}` : '';

// 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 ${sizeClass}">
<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">
<span id="
${this.thid}">${this.title}</span>
<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>
</header>
<div class="
${CLASS_BASE}__body">
<div id="
${this.cid}">${contentString}</div>
<div id="
${this.eid}" class="env-alert env-alert--danger" hidden>
<span data-error-holder></span>
<button type="button" class="env-alert__close env-button env-button--link env-button--icon env-button--icon-small" data-dismiss="alert">
Close
<svg class="env-icon env-icon--xx-small">
<use href="/sitevision/envision-icons.svg#icon-delete"></use>
</svg>
</button>
</div>
</div>
<footer id="
${this.fid}" class="${CLASS_BASE}__footer env-d--flex env-flex--justify-content-between">
<button type="button" data-modal-dialog-dismiss class="env-button env-m-left--a">
${this.closeText}</button>
</footer>
</section>
</div>
</div>
`

));

if (initialContentError) {
this.renderError(initialContentError);
}

Events.onClick(this.el(this.eid).querySelector('[data-dismiss="alert"]'), () => {
this.renderError('');
});

if (initialContent instanceof HTMLElement) {
this.el(this.cid).appendChild(initialContent);
}

return this;
}

isOpen () {
return this.el(this.id)?.classList.contains('env-modal-dialog--show');
}

/**
* @param {string} title
*/

updateTitle (title) {
this.title = title;
this.el(this.thid).innerHTML = title;
}

/**
* @param {string} html
*/

updateContent (html) {
this.el(this.cid).innerHTML = html;
}

/**
* @param {boolean} isLoading
*/

setLoading (isLoading) {
this.isLoading = !!isLoading;
this.el(this.lid).classList.toggle(`${SPINNER_CLASS}--hide`, !this.isLoading);
}

setDisabled (isDisabled) {
this.isDisabled = !!isDisabled;
this.el(this.id).inert = this.isDisabled;
this.el(this.cid).style.opacity = this.isDisabled ? 0.5 : 1;
this.el(this.fid).querySelectorAll('.env-button').forEach((btn) => btn.disabled = this.isDisabled);
}

/**
* @param {string} errorMessage
*/

renderError (errorMessage) {
const hasError = !!errorMessage;
const errorElement = this.el(this.eid);

errorElement.querySelector('[data-error-holder]').innerHTML = errorMessage;
errorElement.hidden = !hasError;

this.el(this.cid).hidden = hasError;
}

onClose (callback) {
if (typeof callback === 'function') {
// TODO: should probably start using new Dialog api instead of old ModalDialog so we can
// remove jQuery as a dependency.
jQuery('#' + this.id).on('hide.env-modal-dialog', (e) => {
callback.call(null, this, e);
});
}

return this;
}

onClosed (callback) {
if (typeof callback === 'function') {
// TODO: should probably start using new Dialog api instead of old ModalDialog so we can
// remove jQuery as a dependency.
jQuery('#' + this.id).on('hidden.env-modal-dialog', (e) => {
callback.call(null, this, e);
});
}

return this;
}

hide (callback) {
ENVISION.dialog('#' + this.id, 'hide').then(callback ? callback.bind(null, this) : null);
return this;
}

/**
* @param {Function} callback Callback run when Envision.dialog promise has finished.
* @returns {Dialog}
*/

toggle (callback) {
ENVISION.dialog('#' + this.id, 'toggle').then(callback ? callback.bind(null, this) : null);
return this;
}

remove () {
this.el(this.id).remove();
}
};

export default Dialog;

/**
* @returns {Record<string,Dialog>}
*/

export const getCache = (key = null) => {
const instances = window.__BookmarkletDialogInstances = window.__BookmarkletDialogInstances || {};

if (key) {
return instances[key];
}

return instances;
};

/**
* @returns {Dialog}
*/

export const getInstance = (dialogId, opts = {}) => {
const instances = getCache();

if (!instances[dialogId]) {
instances[dialogId] = new Dialog({ ...opts, dialogId });
}

return instances[dialogId];
};

export const removeInstance = (dialogId) => {
const instances = getCache();

if (instances[dialogId]) {
instances[dialogId].remove();
delete instances[dialogId];
}
};

ensure-styles.js

const DOCUMENT = window.document;

export default function ensureStyles (id, css) {
let link = document.getElementById(id);

// https://www.npmjs.com/package/clean-css#minify-method
// https://github.com/TrySound/rollup-plugin-string/blob/master/index.js

if (!link) {
DOCUMENT.head.insertAdjacentHTML('beforeend', `<style id="${id}">${css}</style>`);
link = document.getElementById(id);
}

return link;
}

VTable.css

.__VTable-container {
overflow-y: auto;
}

.__VTable-container table {
table-layout: fixed;
}

.__VTable-container thead {
position: sticky;
top: 0;
z-index: 2;
}

.__VTable-container thead tr {
background: var(--env-section-background-color);
}

.__VTable-container thead th,
.__VTable-container tbody td
{
box-sizing: border-box;
flex: 1;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}

.__VTable-container thead th:first-child,
.__VTable-container tbody td:first-child
{
flex: initial;
width: 6em;
}

.__VTable-container tbody {
position: relative;
}

.__VTable-container tbody tr {
display: flex;
position: absolute;
width: 100%;
}

.__VTable-container .env-table--hover tr:hover {
cursor: pointer;
}

VTable.js

import createElement from "./create-element";
import ensureStyles from "./ensure-styles";
import css from "./VTable.css";

const VTABLE_STYLES_ID = '__VTable-styles'
const VTABLE_BASE_CLASS = '__VTable-container';

const DEFAULT_OPTS = {
height: 300,
caption: '',
};

export default class VTable {
constructor (headers, data, options = {}) {
this.headers = headers;
this.data = data;
this.dataFormat = null;
this.rowHeight = 27.5; // Default height. Is updated on init.
this.cols = [];
this.el = null;
this.bodyEl = null;
this.pool = [];
this.opts = { ...DEFAULT_OPTS, ...options };
this.start = 0;

this.init();
}

init () {
const headers = Object.values(this.headers);
const opts = this.opts;
const caption = opts.caption;
const height = opts.height;

ensureStyles(VTABLE_STYLES_ID, css);

this.cols = Object.keys(this.headers);
this.el = createElement(
`<div class="${VTABLE_BASE_CLASS}" style="height:${height}px;">
<table class="env-table env-table--small env-table--hover env-w--100">
${!!caption ? `<caption class="env-assistive-text">${caption}</caption>` : ''}
<thead class="env-d--block env-w--100"><tr class="env-d--flex">
${headers.map((h, i) => `<th>${h}</th>`).join('')}</tr></thead>
<tbody class="env-d--block" style="height:
${height}px;"></tbody>
</table>
</div>
`

);

this.bodyEl = this.el.querySelector('tbody');
}

setData (data) {
this.data = !Array.isArray(data) ? [] : data.map((dataRow, rowIndex) => {
dataRow.$index = rowIndex + 1;
return dataRow;
});

this.numRows = this.data.length;
}

getRowData (rowIndex) {
return this.data[rowIndex];
}

renderRows (scrollTop, force = false) {
const newStart = Math.floor(scrollTop / this.rowHeight);

if (force || this.start !== newStart) {
this.start = newStart;

for (let i = 0; i < this.pool.length; i++) {
const rowIndex = this.start + i;

if (rowIndex < this.numRows) {
const row = this.pool[i];
const rowCells = row.children;

// Update columns with data for the row
for (const [ columnIndex, columnKey ] of this.cols.entries()) {
rowCells[columnIndex].textContent = this.data[rowIndex][columnKey];
}

row.dataset.rowIndex = rowIndex;
row.style.transform = `translateY(${rowIndex * this.rowHeight}px)`;
row.style.display = ''; // TODO: Do I really need to hide/show these anymore? Should always only be 2 extra.
} else {
this.pool[i].style.display = 'none'; // TODO: Same here. Needed?
}
}
}
}

computeRowHeight () {
const tr = createElement(`<tr><td>&nbsp;</td></tr>`);
this.bodyEl.appendChild(tr);
this.rowHeight = tr.getBoundingClientRect().height; // offsetHeight;
this.bodyEl.removeChild(tr);

return this.rowHeight;
}

computeVisibleRows () {
const visibleRows = Math.max(Math.ceil(this.el.clientHeight / this.rowHeight) + 2, 10); // Number of visible rows, plus buffer

return visibleRows;
}

resetPool () {
const visibleRows = this.computeVisibleRows();

this.pool = [];
this.bodyEl.innerHTML = '';

for (let i = 0; i < visibleRows; i++) {
const tr = createElement(`<tr>${'<td></td>'.repeat(this.cols.length)}</tr>`);
this.pool.push(tr);
this.bodyEl.appendChild(tr);
}
}

computeTableDimensions () {
this.computeRowHeight();
this.computeVisibleRows();
this.resetPool();

this.bodyEl.style.height = (this.numRows * this.rowHeight) + 'px';
}

renderTo (target) {
target.appendChild(this.el);

this.el.addEventListener('scroll', () => {
this.renderRows(this.el.scrollTop);
});

this.renderRows(0, true);
}

onRowClick (callback) {
if (typeof callback === 'function') {
this.bodyEl.addEventListener('click', (event) => {
const eventTarget = event.target;
const rowIndex = parseInt((eventTarget?.tagName === 'TR' ? eventTarget : eventTarget.closest('tr'))?.dataset.rowIndex, 10);

if (Number.isInteger(rowIndex)) {
const rowData = this.getRowData(rowIndex);

callback.call(null, rowData, rowIndex);
}
});
}
}

update (data) {
this.setData(data);

this.bodyEl.style.height = (this.numRows * this.rowHeight) + 'px';

// Reset scroll when updating data
this.el.scrollTop = 0;
this.renderRows(0, true);
}
}

uid.js

export default function uid () {
return '-' + Math.random().toString(36).slice(2, 11);
}

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, returnError = false }) {
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';
}

let response;
let data = {};
try {
response = await fetch(url, fetchOpts);
data = await response.json();
} catch (e) {
response = { ok: false, status: response.status };
data = { message: String(e) };
}

let e = null;
if (!response.ok) {
e = [
`Status ${response.status}`,
data?.description,
data?.message,
].filter(v => !!v).join(', ');

if (!returnError) {
throw e;
}
}

if (returnError) {
return [ data, e ];
}

return data;
}

toasts.js

const DOCUMENT = window.document;

/**
* @typedef {Object} ToastOptions
* @property {"success" | "primary" | ""} [type=""] The type of toast to emit.
* @property {number} [ttl=4] (time to live) in seconds for toast to be shown.
* @property {Function} [callback] A callback function executed when toast is removed.
* @property {Boolean} [checkmark=undefined] Boolean value if checkmark should be shown.
* @property {string} [heading=""] A header string, (not required), will be <strong>.
* @property {string} message A message string.
*/


/**
* @param {ToastOptions} options
*/

export const publish = function publish (options) {
DOCUMENT.dispatchEvent(new CustomEvent('sv-publish-toast', {
detail: {
type: '',
...options,
}
}));
};

export default { publish };