🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@jinntec/fore

Package Overview
Dependencies
Maintainers
3
Versions
63
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@jinntec/fore - npm Package Compare versions

Comparing version
3.0.1
to
3.1.0
+231
src/authoring-check.js
/**
* 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;
}
// fx-speech.js — Fore-compatible voice input component with focus alignment, restart, repeat/back commands, and visual listening indicator
class FxSpeech extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.mode = this.getAttribute('mode') || 'guided';
this.currentIndex = 0;
this.controls = [];
this.recognition = null;
this.lastInputCaptured = false;
this.awaitingInput = false;
this.waitingToAdvance = false;
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
button { margin: 0.5em; padding: 0.5em 1em; font-size: 1em; }
#status { display: inline-block; margin-left: 1em; font-weight: bold; color: green; visibility: hidden; }
#status.listening { visibility: visible; animation: pulse 1s infinite; }
@keyframes pulse {
0% { opacity: 0.3; }
50% { opacity: 1; }
100% { opacity: 0.3; }
}
</style>
<button id="start">🎤 Start Speech Input</button>
<button id="retry" style="display:none;">🔁 Continue</button>
<span id="status">🎧 Listening…</span>
`;
this.controls = Array.from(document.querySelectorAll('fx-control'));
this.initSpeech();
this.shadowRoot.getElementById('start').addEventListener('click', () => {
this.startInteraction();
});
this.shadowRoot.getElementById('retry').addEventListener('click', () => {
this.startGuided();
});
document.addEventListener('focusin', e => {
const targetControl = e.target.closest('fx-control');
if (targetControl) {
const index = this.controls.indexOf(targetControl);
if (index !== -1) {
this.currentIndex = index;
}
}
});
}
initSpeech() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
alert('Web Speech API not supported in this browser.');
return;
}
this.recognition = new SpeechRecognition();
this.recognition.lang = 'en-US';
this.recognition.interimResults = false;
this.recognition.continuous = false;
this.recognition.onresult = event => {
this.lastSpoken = null;
const spoken = event.results[0][0].transcript.trim();
console.log('Recognized:', spoken);
this.lastInputCaptured = true;
this.awaitingInput = false;
this.toggleListening(false);
if (this.mode === 'guided') {
this.lastSpoken = spoken.toLowerCase();
this.applyGuidedInput(this.lastSpoken);
} else {
this.handleCommandInput(spoken.toLowerCase());
}
};
this.recognition.onerror = e => {
console.warn('Speech error:', e.error);
this.awaitingInput = false;
this.toggleListening(false);
if (this.mode === 'guided' && !this.waitingToAdvance) this.retryGuided();
};
this.recognition.onend = () => {
this.recognitionActive = false;
console.log('Recognition ended');
this.toggleListening(false);
if (this.mode === 'guided') {
if (this.lastInputCaptured && !['next', 'back'].includes(this.lastSpoken)) {
this.advanceToNextField();
} else if (this.awaitingInput && !this.waitingToAdvance) {
this.retryGuided();
}
}
};
this.recognition.onstart = () => {
this.recognitionActive = true;
console.log('Recognition started');
this.toggleListening(true);
};
}
toggleListening(state) {
const status = this.shadowRoot.getElementById('status');
if (state) {
status.classList.add('listening');
} else {
status.classList.remove('listening');
}
}
speak(text, callback) {
const utterance = new SpeechSynthesisUtterance(text);
utterance.onend = async () => {
await this.waitForSpeechSynthesisToEnd();
if (callback) callback();
};
speechSynthesis.speak(utterance);
}
async waitForSpeechSynthesisToEnd() {
while (speechSynthesis.speaking) {
await new Promise(resolve => setTimeout(resolve, 50));
}
}
getLabelText(control) {
return (
control.getAttribute('aria-label') ||
control.querySelector('label')?.textContent?.trim() ||
'unknown field'
);
}
getInputElement(control) {
return control.querySelector('input, textarea, select');
}
startInteraction() {
this.shadowRoot.getElementById('retry').style.display = 'none';
if (this.mode === 'guided') {
this.startGuided();
} else {
this.recognition.start();
}
}
startGuided() {
this.shadowRoot.getElementById('retry').style.display = 'none';
if (this.currentIndex >= this.controls.length) {
this.speak('All fields completed.', () => {
this.currentIndex = 0;
this.shadowRoot.getElementById('start').textContent = '🔁 Restart Speech Input';
this.shadowRoot.getElementById('retry').style.display = 'inline-block';
});
return;
}
this.lastInputCaptured = false;
this.awaitingInput = true;
this.waitingToAdvance = false;
const control = this.controls[this.currentIndex];
const label = this.getLabelText(control);
const input = this.getInputElement(control);
input?.focus();
console.log('Prompting for field:', label);
this.speak(`Please say value for ${label}`, () => {
console.log('Starting recognition for:', label);
if (!this.recognitionActive) this.recognition.start();
});
}
retryGuided() {
this.shadowRoot.getElementById('retry').style.display = 'inline-block';
this.awaitingInput = false;
this.speak('Please try again or tap continue.');
}
applyGuidedInput(spoken) {
if (spoken === 'clear') {
const control = this.controls[this.currentIndex];
const input = this.getInputElement(control);
if (input) {
input.value = '';
input.dispatchEvent(new Event('input', { bubbles: true }));
this.speak('Cleared');
}
return;
}
if (spoken === 'next') {
this.advanceToNextField();
return;
}
if (spoken === 'repeat') {
this.startGuided();
return;
}
if (spoken === 'back') {
this.currentIndex = Math.max(0, this.currentIndex - 1);
this.startGuided();
return;
}
const control = this.controls[this.currentIndex];
const input = this.getInputElement(control);
if (input) {
input.value = spoken;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
}
advanceToNextField() {
this.waitingToAdvance = true;
setTimeout(() => {
this.currentIndex++;
this.startGuided();
}, 1000);
}
handleCommandInput(spoken) {
if (spoken.startsWith('skip to')) {
const label = spoken.replace('skip to', '').trim();
const target = this.controls.find(ctrl => this.getLabelText(ctrl).toLowerCase() === label);
if (target) {
this.currentIndex = this.controls.indexOf(target);
this.getInputElement(target)?.focus();
this.speak(`Skipping to ${label}`);
} else {
this.speak(`Label "${label}" not found.`);
}
return;
}
if (spoken === 'next') {
this.currentIndex++;
return;
}
if (spoken === 'repeat') {
this.startGuided();
return;
}
if (spoken === 'back') {
this.currentIndex = Math.max(0, this.currentIndex - 1);
this.startGuided();
return;
}
const [label, ...rest] = spoken.split(' ');
const value = rest.join(' ');
const target = this.controls.find(ctrl => this.getLabelText(ctrl).toLowerCase() === label);
if (target) {
const input = this.getInputElement(target);
if (input) {
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.focus();
}
} else {
this.speak(`Label "${label}" not found.`);
}
}
}
customElements.define('fx-speech', FxSpeech);
+1
-0

@@ -70,3 +70,4 @@ // core + models classes

import './src/functions/fx-functionlib.js';
import './src/fx-speech.js';
export default {};
+2
-2
{
"name": "@jinntec/fore",
"version": "3.0.1",
"version": "3.1.0",
"description": "Fore - declarative user interfaces in plain HTML",

@@ -36,3 +36,3 @@ "module": "./index.js",

"chai": "^5.2.1",
"fontoxpath": "^3.33.0"
"fontoxpath": "^3.34.0"
},

@@ -39,0 +39,0 @@ "devDependencies": {

@@ -13,4 +13,2 @@ ![NPM](https://img.shields.io/npm/l/@jinntec/fore)

[![Twitter URL](https://img.shields.io/twitter/url?style=social&url=https%3A%2F%2Ftwitter.com%2FJinnForeTec)](https://twitter.com/JinnForeTec)
[Homepage](https://jinntec.github.io/Fore/) |

@@ -17,0 +15,0 @@ [Documentation](https://jinntec.github.io/fore-docs/) |

@@ -114,4 +114,5 @@ import { AbstractAction } from './abstract-action.js';

// Keep it robust against whitespace/formatting
const raw = String(tplEl.textContent || '').trim();
// <template>.textContent is "" because content lives in template.content (DocumentFragment).
// innerHTML reads from the content fragment correctly.
const raw = String(tplEl.innerHTML || tplEl.textContent || '').trim();
if (!raw) return null;

@@ -118,0 +119,0 @@ return raw;

@@ -20,3 +20,3 @@ import getInScopeContext from './getInScopeContext.js';

static RELEVANT_DEFAULT = true
static RELEVANT_DEFAULT = true;

@@ -271,3 +271,2 @@ static CONSTRAINT_DEFAULT = true;

static async initUI(startElement) {

@@ -306,9 +305,8 @@ const inited = new Promise(resolve => {

if (Fore.isUiElement(element.nodeName) && typeof element.refresh === 'function') {
/** @type {import('./ForeElementMixin.js').default} */
/** @type {import('./ui/UIElement.js').UIElement} */
const bound = element;
// Keep old behavior: only refresh UI elements during full/forced refresh
if (!force) {
// still recurse below
} else if (force === true) {
// Any #refresh call does its own recursion.
if (force === true) {
const maybePromise = bound.refresh(force);

@@ -318,3 +316,5 @@ if (maybePromise && typeof maybePromise.then === 'function') {

}
} else if (typeof force === 'object') {
continue;
}
if (typeof force === 'object') {
// future selective refresh logic can live here if you re-enable it

@@ -325,2 +325,3 @@ const maybePromise = bound.refresh(force);

}
continue;
}

@@ -529,6 +530,3 @@ }

const contexp = /(<.+>)(.+\n)/g;
xml = xml
.replace(reg, '$1\n$2$3')
.replace(wsexp, '$1\n')
.replace(contexp, '$1\n$2');
xml = xml.replace(reg, '$1\n$2$3').replace(wsexp, '$1\n').replace(contexp, '$1\n$2');
let formatted = '';

@@ -535,0 +533,0 @@ const lines = xml.split('\n');

@@ -323,3 +323,9 @@ import { Fore } from './fore.js';

} else if (this.type === 'json') {
this._setInitialData(JSON.parse(this.textContent));
// Use innerHTML (not textContent) so HTML tags the browser parser consumed as
// child elements (e.g. <blockquote> in a string value) are serialized back to text.
// Then escape literal control characters that JSON.parse rejects inside strings.
const sanitized = this.innerHTML.replace(/("(?:[^"\\]|\\.)*")/gs, match =>
match.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t'),
);
this._setInitialData(JSON.parse(sanitized));
} else if (this.type === 'html') {

@@ -326,0 +332,0 @@ this._setInitialData(this.firstElementChild.children);

@@ -338,2 +338,3 @@ import '../fx-model.js';

// todo - review alert handling altogether. There could be potentially multiple ones in model
// TODO: both required and handleValid set valid attrs and aria attrs. Duplicate code
handleValid() {

@@ -343,4 +344,9 @@ // console.log('mip valid', this.modelItem.required);

// console.log('late modelItem', mi);
if (this.isValid() !== this.modelItem.constraint) {
if (this.modelItem.constraint) {
const hasValue = this.modelItem.value !== '';
const isRequired = this.modelItem.required;
const isValidAccordingToRequired = isRequired ? hasValue : true;
const isValidNow = this.modelItem.constraint && isValidAccordingToRequired;
if (this.isValid() !== isValidNow) {
if (isValidNow) {
// if (alert) alert.style.display = 'none';

@@ -351,2 +357,4 @@ this._dispatchEvent('valid');

this.getWidget().setAttribute('aria-invalid', 'false');
// also reset other dependent CSS classes
this.classList.remove('isEmpty');
} else {

@@ -385,3 +393,2 @@ this.setAttribute('invalid', '');

this._syncAriaInvalid();
}

@@ -407,8 +414,8 @@

if (newEnabled) {
this.setAttribute('relevant', '');
this.setAttribute('relevant', '');
this.removeAttribute('nonrelevant');
} else {
this.setAttribute('nonrelevant', '');
} else {
this.setAttribute('nonrelevant', '');
this.removeAttribute('relevant');
}
}

@@ -415,0 +422,0 @@ // Dispatch only on actual change

@@ -68,2 +68,3 @@ // import { foreElementMixin } from '../ForeElementMixin';

const ownerForm = this.getOwnerForm();
let target = this;
if (this.src) {

@@ -83,5 +84,6 @@ // We will replace the node. So this node will be detached after these async function

await parentNode.replaceCase(this, replacement);
target = replacement;
}
const model = ownerForm.getModel();
ownerForm.addToBatchedNotifications(this);
ownerForm.addToBatchedNotifications(target);
ownerForm.refresh(false);

@@ -88,0 +90,0 @@ });

@@ -444,3 +444,3 @@ import XfAbstractControl from './abstract-control.js';

// ### when there's a src Fore is used as widget and will be loaded from external file
if (this.src && !this.loaded && this.modelItem.relevant) {
if (this.src && !this.loaded && !this.loading && this.modelItem.relevant) {
// ### evaluate initial data if necessary

@@ -453,5 +453,7 @@

this.loading = true;
// ### load the markup from src
await this._loadForeFromSrc();
this.loaded = true;
this.loading = false;

@@ -582,3 +584,3 @@ // ### replace default instance of embedded Fore with initial nodes

}
Fore.refreshChildren(this, force);
await Fore.refreshChildren(this, force);
}

@@ -585,0 +587,0 @@

@@ -96,3 +96,3 @@ import { Fore } from '../fore.js';

// context item
Fore.refreshChildren(this, !!force);
return Fore.refreshChildren(this, !!force);
}

@@ -99,0 +99,0 @@

@@ -122,45 +122,15 @@ import { Fore } from '../fore.js';

if (this.mediatype === 'html') {
if (this.modelItem.node) {
const defaultSlot = this.shadowRoot.querySelector('#default');
const { node } = this.modelItem;
if (node.nodeType) {
valueWrapper.append(node);
// this.appendChild(node);
// JSON instances use a lens, so modelItem.node is null — fall back to this.value
const source = this.modelItem.node ?? this.value;
if (source) {
if (source.nodeType) {
valueWrapper.append(source);
return;
}
// ### try to parse as string
const tmpDoc = new DOMParser().parseFromString(node, 'text/html');
const theNode = tmpDoc.body.childNodes;
// console.log('actual node', theNode)
Array.from(theNode).forEach(n => {
// parse string as HTML
const tmpDoc = new DOMParser().parseFromString(source, 'text/html');
Array.from(tmpDoc.body.childNodes).forEach(n => {
valueWrapper.append(n);
});
// valueWrapper.append(theNode);
// valueWrapper.innerHTML=node;
/*
if (node.nodeType) {
this.appendChild(node);
return;
}
Object.entries(node).map(obj => {
// valueWrapper.appendChild(obj[1]);
this.appendChild(obj[1]);
});
*/
/*
Object.entries(node).map(obj => {
// valueWrapper.appendChild(obj[1]);
this.appendChild(obj[1]);
});
*/
return;
}
// this.innerHTML = this.value.outerHTML;
// valueWrapper.innerHTML = this.value.outerHTML;
// this.shadowRoot.appendChild(this.value);
return;

@@ -167,0 +137,0 @@ }

@@ -347,3 +347,3 @@ import { Fore } from '../fore.js';

if (!fore.lazyRefresh || force) {
Fore.refreshChildren(this, force);
await Fore.refreshChildren(this, force);
}

@@ -350,0 +350,0 @@ // this.style.display = 'block';

@@ -1038,3 +1038,3 @@ import './fx-repeatitem.js';

if (!fore.lazyRefresh || force) {
Fore.refreshChildren(this, force);
await Fore.refreshChildren(this, force);
}

@@ -1041,0 +1041,0 @@

@@ -130,3 +130,3 @@ /**

// Turn the possibly conditional force refresh into a forced one: we changed our children
Fore.refreshChildren(this, force);
await Fore.refreshChildren(this, force);
}

@@ -481,5 +481,5 @@ // this.style.display = 'block';

modelItem.parentModelItem = parentModelItem;
// IMPORTANT: keep using the canonical instance returned by registerModelItem.
// Otherwise a throwaway ModelItem can leak into observer graphs and be notified.
modelItem = this.getModel().registerModelItem(modelItem);
// IMPORTANT: keep using the canonical instance returned by registerModelItem.
// Otherwise a throwaway ModelItem can leak into observer graphs and be notified.
modelItem = this.getModel().registerModelItem(modelItem);
}

@@ -486,0 +486,0 @@

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display