@jinntec/fore
Version:
Fore - declarative user interfaces in plain HTML
232 lines (214 loc) • 6.71 kB
JavaScript
/**
* Authoring integrity checks for Fore forms.
*
* Runs by default at startup. Add the `no-check` attribute to `<fx-fore>` to disable
* (e.g. in production). The module is dynamically imported, so it is never loaded
* when checks are disabled.
*
* Adding a new check: add a function `_check<Name>(fore, errors)` and call it in
* `checkAuthoring()` below.
*/
const INSTANCE_RE = /instance\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
const INDEX_RE = /index\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
// Attributes that may carry XPath expressions
const XPATH_ATTRS = [
'ref',
'value',
'calculate',
'constraint',
'required',
'readonly',
'relevant',
'bind',
'context',
'if',
'while',
'origin',
'iterate',
'at',
];
function _isDynamic(val) {
return !val || val.includes('{');
}
function _byId(fore, id) {
return (
fore.ownerDocument.getElementById(id) ||
fore.getRootNode().getElementById?.(id) ||
fore.querySelector(`#${id}`)
);
}
function _checkSendSubmissions(fore, errors) {
fore.querySelectorAll('fx-send[submission]').forEach(el => {
const id = el.getAttribute('submission');
if (_isDynamic(id)) return;
const localFore = el.closest('fx-fore');
const { model } = localFore;
const target = model
? model.querySelector(`fx-submission#${id}`)
: fore.querySelector(`fx-submission#${id}`);
if (!target) {
errors.push({
element: el,
message: `<fx-send submission="${id}">: no <fx-submission id="${id}"> found`,
});
}
});
}
function _checkDispatchTargets(fore, errors) {
fore.querySelectorAll('fx-dispatch[targetid]').forEach(el => {
const id = el.getAttribute('targetid');
if (_isDynamic(id)) return;
if (!_byId(fore, id)) {
errors.push({
element: el,
message: `<fx-dispatch targetid="${id}">: no element with id="${id}" found`,
});
}
});
}
function _checkXPathInstanceRefs(fore, errors) {
const allEls = Array.from(fore.querySelectorAll('*'));
for (const el of allEls) {
const localFore = el.closest('fx-fore');
for (const attr of XPATH_ATTRS) {
const val = el.getAttribute(attr);
if (!val) continue;
INSTANCE_RE.lastIndex = 0;
let m;
while ((m = INSTANCE_RE.exec(val)) !== null) {
const id = m[1];
const localInstance = localFore.querySelector(`fx-instance#${id}`);
const sharedInstance =
!localInstance && localFore.ownerDocument.querySelector(`fx-instance[shared]#${id}`);
if (!localInstance && !sharedInstance) {
errors.push({
element: el,
message: `[${attr}="${val}"]: instance('${id}') — no <fx-instance id="${id}"> found`,
});
}
}
INDEX_RE.lastIndex = 0;
while ((m = INDEX_RE.exec(val)) !== null) {
const id = m[1];
if (!localFore.querySelector(`fx-repeat#${id}`)) {
errors.push({
element: el,
message: `[${attr}="${val}"]: index('${id}') — no <fx-repeat id="${id}"> found`,
});
}
}
}
}
}
function _checkCallActions(fore, errors) {
fore.querySelectorAll('fx-call[action]').forEach(el => {
const id = el.getAttribute('action');
if (_isDynamic(id)) return;
if (!_byId(fore, id)) {
errors.push({
element: el,
message: `<fx-call action="${id}">: no element with id="${id}" found`,
});
}
});
}
function _checkShowHideDialogs(fore, errors) {
fore.querySelectorAll('fx-show[dialog], fx-hide[dialog]').forEach(el => {
const id = el.getAttribute('dialog');
if (_isDynamic(id)) return;
if (!_byId(fore, id)) {
errors.push({
element: el,
message: `<${el.localName} dialog="${id}">: no element with id="${id}" found`,
});
}
});
}
function _checkLoadAttachTo(fore, errors) {
fore.querySelectorAll('fx-load[attach-to]').forEach(el => {
const val = el.getAttribute('attach-to');
if (_isDynamic(val)) return;
if (!val.startsWith('#')) return; // _blank, _self etc. are valid non-id targets
const id = val.substring(1);
if (!_byId(fore, id)) {
errors.push({
element: el,
message: `<fx-load attach-to="${val}">: no element with id="${id}" found`,
});
}
});
}
function _checkRefreshControl(fore, errors) {
fore.querySelectorAll('fx-refresh[control]').forEach(el => {
const id = el.getAttribute('control');
if (_isDynamic(id)) return;
if (!_byId(fore, id)) {
errors.push({
element: el,
message: `<fx-refresh control="${id}">: no element with id="${id}" found`,
});
}
});
}
function _checkResetInstance(fore, errors) {
const model = fore.querySelector(':scope > fx-model');
fore.querySelectorAll('fx-reset[instance]').forEach(el => {
const id = el.getAttribute('instance');
if (_isDynamic(id)) return;
const target = model
? model.querySelector(`fx-instance#${id}`)
: fore.querySelector(`fx-instance#${id}`);
const sharedTarget = !target && fore.ownerDocument.querySelector(`fx-instance[shared]#${id}`);
if (!target && !sharedTarget) {
errors.push({
element: el,
message: `<fx-reset instance="${id}">: no <fx-instance id="${id}"> found`,
});
}
});
}
function _checkSetfocusControl(fore, errors) {
fore.querySelectorAll('fx-setfocus[control]').forEach(el => {
const id = el.getAttribute('control');
if (_isDynamic(id)) return;
if (!_byId(fore, id)) {
errors.push({
element: el,
message: `<fx-setfocus control="${id}">: no element with id="${id}" found`,
});
}
});
}
function _checkToggleCase(fore, errors) {
fore.querySelectorAll('fx-toggle[case]').forEach(el => {
const id = el.getAttribute('case');
if (_isDynamic(id)) return;
if (!fore.querySelector(`fx-case#${id}`)) {
errors.push({
element: el,
message: `<fx-toggle case="${id}">: no <fx-case id="${id}"> found`,
});
}
});
}
/**
* Run all authoring checks on a given `<fx-fore>` element.
* Returns an array of `{ element, message }` error objects.
*
* @param {HTMLElement} fore
* @returns {{ element: HTMLElement, message: string }[]}
*/
export function checkAuthoring(fore) {
const errors = [];
_checkSendSubmissions(fore, errors);
_checkDispatchTargets(fore, errors);
_checkXPathInstanceRefs(fore, errors);
_checkCallActions(fore, errors);
_checkShowHideDialogs(fore, errors);
_checkLoadAttachTo(fore, errors);
_checkRefreshControl(fore, errors);
_checkResetInstance(fore, errors);
_checkSetfocusControl(fore, errors);
_checkToggleCase(fore, errors);
return errors;
}