├── .github
└── FUNDING.yml
├── LICENSE
├── README.md
├── demos
├── Simple Calculator
│ ├── index.html
│ └── script.js
└── Simple Landing Page
│ ├── app.js
│ ├── components
│ ├── footer.js
│ ├── header.js
│ └── hero.js
│ └── index.html
├── lib
└── queflow.js
└── package.json
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: queflowjs
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12 | polar: # Replace with a single Polar username
13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
14 | thanks_dev: # Replace with a single thanks.dev username
15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
16 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Dayson9
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # QueFlowJS
2 |
3 | **_QueFlowJS_** is a JavaScript library for building performant web apps in a declarative manner. Offers some APIs that makes the development of web apps easier and faster.
--------------------------------------------------------------------------------
/demos/Simple Calculator/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Simple Calculator : QueFlowJS
8 |
9 |
10 |
11 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/demos/Simple Calculator/script.js:
--------------------------------------------------------------------------------
1 | import { App } from 'queflow';
2 |
3 |
4 | const styles = {
5 | "*": `
6 | text-align: center;
7 | display: block;
8 | transition: .3s;
9 | font-family: 'sans-serif';
10 | `,
11 | ".out": `
12 | font-weight: 500;
13 | font-size: 20px;
14 | color: rgb(24, 65, 70);
15 | `,
16 | "input": `
17 | width: 60%;
18 | height: 40px;
19 | border: none;
20 | background: rgba(0,0,255,0.1);
21 | border-radius: 5px;
22 | margin-left: 20%;
23 | outline: none;
24 | box-sizing: border-box;
25 | `,
26 | "input:focus": `
27 | outline: none;
28 | border: 2px solid rgba(0,55,255,0.4);
29 | `,
30 |
31 | "button": `
32 | width: 60%;
33 | height: 40px;
34 | border: 2px solid rgba(0, 90, 255, .1);
35 | background: rgba(0, 90, 255, .7);
36 | border-radius: 5px;
37 | margin-left: 20%;
38 | color: white;
39 | margin-top: 20px;
40 | transition: 0.3s;
41 | `,
42 |
43 | "button:active": `
44 | background: rgba(0, 90, 255, .86);
45 | `
46 | };
47 |
48 |
49 | const calculator = new App("#app", {
50 | data: {
51 | out: 20*20
52 | },
53 | stylesheet: styles,
54 | template: () => {
55 | return (
56 | `
57 | Simple Calculator App
58 |
59 | Output: {{ out }}
60 |
61 |
62 |
63 | Evaluate
64 | `
65 | )
66 | }
67 | });
68 |
69 | calculator.render();
70 |
--------------------------------------------------------------------------------
/demos/Simple Landing Page/app.js:
--------------------------------------------------------------------------------
1 | import Header from './components/header.js';
2 | import Hero from './components/hero.js';
3 | import Footer from './components/footer.js';
4 |
5 | import { App } from 'queflow';
6 |
7 |
8 | const LandingPage = new App("#app", {
9 | stylesheet: {
10 | "*": `
11 | font-family: Sans-serif;
12 | text-align: center;
13 | `,
14 | ".green": "color: #148A81",
15 | ".dark": "color: rgb(6,27,60);",
16 | },
17 | template: () => {
18 | return `
19 |
20 |
21 |
22 | `;
23 | }
24 | });
25 |
26 |
27 | LandingPage.render();
28 |
--------------------------------------------------------------------------------
/demos/Simple Landing Page/components/footer.js:
--------------------------------------------------------------------------------
1 | import { Component } from 'queflow';
2 |
3 | // Define styles for Footer UI.
4 | const styles = {
5 | "footer": `
6 | width: 100vw;
7 | height: 20vh;
8 | background: rgb(6, 27, 60);
9 | box-sizing: border-box;
10 | color: white;
11 | display: flex;
12 | align-items: center;
13 | justify-content: center;
14 | `,
15 | "footer > span": `
16 | font-size: 15px;
17 | font-weight: 400;
18 |
19 | `
20 | };
21 |
22 |
23 | // Define Component for footer
24 | const Footer = new Component('Footer', {
25 | stylesheet: styles,
26 | template: () => `
27 |
28 |
29 | Nothing to see here, just a demo...
30 |
31 |
32 | `
33 | });
34 |
35 | export default Footer;
--------------------------------------------------------------------------------
/demos/Simple Landing Page/components/header.js:
--------------------------------------------------------------------------------
1 | import { Component } from 'queflow';
2 |
3 | // Styles for header UI
4 | const styles = {
5 | "header": `
6 | width: 85%;
7 | height: 65px;
8 | background: white;
9 | border-radius: 15px;
10 | box-shadow: 2px 4px 16px rgba(0,0,0,0.1);
11 | position: fixed;
12 | top: 20px;
13 | left: 7.5%;
14 | display: flex;
15 | flex-direction: row;
16 | justify-content: space-evenly;
17 | align-items: center;
18 | `,
19 | "#img": `
20 | width: 50px;
21 | height: 50px;
22 | border-radius: 50%;
23 | border: 3px solid #148A81;
24 | `,
25 | "button": `
26 | width: 80px;
27 | height: 30px;
28 | border: none;
29 | background: rgb(6,27,60);
30 | color: white;
31 | border-radius: 10px;
32 | `
33 | };
34 |
35 |
36 | // Define Header Component
37 | const Header = new Component('Header', {
38 | stylesheet: styles,
39 | template: () => `
40 |
41 |
42 |
43 | QueFlowJS
44 | View
45 |
46 |
`
47 | });
48 |
49 |
50 | export default Header;
51 |
--------------------------------------------------------------------------------
/demos/Simple Landing Page/components/hero.js:
--------------------------------------------------------------------------------
1 | import { Component } from 'queflow';
2 |
3 |
4 | // Styles declaration for hero UI
5 | const styles = {
6 | ".container": `
7 | width: 100%;
8 | height: auto;
9 | padding-block: 20vh;
10 | padding-inline: 5%;
11 | box-sizing: border-box;
12 | margin-top: 20px;
13 | `,
14 | ".row": `
15 | height: auto;
16 | display: flex;
17 | flex-direction: row;
18 | justify-content: space-around;
19 | align-items: center;
20 | `,
21 | "button": `
22 | width: 140px;
23 | height: 60px;
24 | border-radius: 10px;
25 | border: none;
26 | color: white;
27 | background: rgb(6,27,60);
28 | font-size: 15px;
29 | `,
30 | ".second": "background: #148A81;"
31 | }
32 |
33 | // Define Component for hero
34 | const Hero = new Component('Hero', {
35 | stylesheet: styles,
36 | template: () => `
37 |
38 |
39 |
40 | QueFlowJS The Modern UI Library
41 |
42 |
A highly performant UI library for declaratively building web User Interfaces.
43 |
44 | Documentation
45 | Repository
46 |
47 |
48 |
49 | `
50 | });
51 |
52 | export default Hero;
--------------------------------------------------------------------------------
/demos/Simple Landing Page/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Simple Landing Page : QueFlowJS
8 |
14 |
15 |
16 |
17 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/lib/queflow.js:
--------------------------------------------------------------------------------
1 | /*!
2 | * QueFlow.js
3 | * (c) 2024-now Sodiq Tunde (Dayson9)
4 | * Released under the MIT License.
5 | */
6 | 'use-strict';
7 |
8 | // Counter for generating unique IDs for elements with reactive data.
9 | var counterQF = -1,
10 | nuggetCounter = -1,
11 | routerObj = {},
12 | currentComponent,
13 | navigateFunc = (() => {}),
14 | globalStateDataQF = [];
15 |
16 | var stylesheet = {
17 | el: document.createElement("style"),
18 | isAppended: false
19 | };
20 |
21 | const components = new Map(),
22 | nuggets = new Map();
23 |
24 | // Selects an element in the DOM using its data-qfid attribute.
25 | const selectElement = qfid => document.querySelector("[data-qfid=" + qfid + "]");
26 |
27 |
28 | const strToEl = (component) => {
29 | const id = component.element;
30 | if (typeof id === "string") {
31 | component.element = document.getElementById(id);
32 | }
33 | }
34 |
35 | // Filters out null elements from the given input [Array].
36 | function filterNullElements(input) {
37 | return input.filter((d) => selectElement(d.qfid) ? d : null);
38 | }
39 |
40 | const globalState = (name, val, shouldStore) => {
41 | let stored;
42 | if (shouldStore) {
43 | stored = localStorage.getItem(name);
44 | val = stored ? JSON.parse(stored) : val;
45 | }
46 | const obj = typeof val === "object" ? val : { value: val };
47 | const reactiveObj = (object) => {
48 | return new Proxy(object, {
49 | get(target, key) {
50 | return target[key];
51 | },
52 | set(target, key, value) {
53 | if (target[key] !== value) {
54 | target[key] = value;
55 | updateComponent(key, true, value);
56 | if (shouldStore) localStorage.setItem(name, JSON.stringify(target));
57 | }
58 | }
59 | })
60 | }
61 | globalThis[name] = reactiveObj(obj);
62 | }
63 |
64 | // Creates a reactive signal, a proxy object that automatically updates the DOM/Component when its values change.
65 | function createSignal(data, object) {
66 | const item = typeof data != "object" ? { value: data } : data;
67 |
68 | function createReactiveObject(obj) {
69 | if (typeof obj !== "object") return obj;
70 |
71 | return new Proxy(obj, {
72 | get(target, key) {
73 | return createReactiveObject(target[key]); // Make nested objects reactive
74 | },
75 | set(target, key, value) {
76 | const prev = target[key];
77 | if (prev !== value) {
78 | target[key] = value;
79 | requestAnimationFrame(() => {
80 | const host = object;
81 | if (!host.isFrozen) {
82 | const goAhead = host.onUpdate ? host.onUpdate({
83 | oldVal: prev,
84 | key: key,
85 | newVal: value
86 | }, host.data) : true;
87 | if (goAhead) updateComponent(key, host, value);
88 | return true;
89 | }
90 | });
91 | }
92 | return true;
93 | },
94 | });
95 | }
96 |
97 | return createReactiveObject(item);
98 | }
99 |
100 | const b = str => stringBetween(str, "{{", "}}");
101 |
102 | // Checks if a DOM element has child elements.
103 | const hasChildren = (element) => element.children.length;
104 |
105 | // Extracts the string between two delimiters in a given string.
106 | function stringBetween(str, f, s) {
107 | const indx1 = str.indexOf(f),
108 | indx2 = str.indexOf(s);
109 |
110 | return str.slice(indx1 + f.length, indx2);
111 | }
112 |
113 | // Sanitizes a string to prevent potential XSS attacks.
114 | function sanitizeString(str) {
115 | const excluded_chars = [{ from: "<", to: "<" }, { from: ">", to: ">" }];
116 |
117 | str = new String(str);
118 |
119 | for (const index in excluded_chars) {
120 | const { from, to } = excluded_chars[index];
121 | str = str.replaceAll(from, to);
122 | }
123 |
124 | return str.replace(/javascript:/gi, '');
125 | }
126 |
127 |
128 | const EVAL_REGEX = /\{\{[^\{\{]+\}\}/g;
129 | const ENTITY_REGEX = /&(gt|lt);/g;
130 | const FALSY = [undefined, NaN, null];
131 |
132 | function evaluateTemplate(reff, instance) {
133 | let out = "",
134 | currentMarkup = "";
135 |
136 | try {
137 | out = reff.replace(EVAL_REGEX, (match) => {
138 | currentMarkup = match;
139 | // Combined HTML entity replacement in single pass
140 | const processedMatch = match.replace(ENTITY_REGEX, (_, entity) =>
141 | entity === 'gt' ? '>' : '<'
142 | );
143 | let ext = b(processedMatch).trim();
144 | const shouldNegate = ext.startsWith('!');
145 | const isGlobal = ext.startsWith('$');
146 | const prefix = ext.startsWith('this') ? '' : 'this.data.';
147 | let rendered;
148 |
149 | if (!isGlobal) {
150 | ext = shouldNegate ?
151 | `!${prefix}${ext.slice(1)}` :
152 | `${prefix}${ext}`;
153 |
154 | let parsed = Function(`return ${ext}`).call(instance);
155 | if (FALSY.includes(parsed) && parsed != "0") {
156 | parsed = Function(`return ${ext}`).call(instance);
157 | }
158 |
159 | rendered = FALSY.includes(parsed) && parsed != "0" ?
160 | match :
161 | parsed;
162 | } else {
163 | rendered = Function(`return ${ext}`)();
164 | }
165 | return rendered;
166 | });
167 | } catch (error) {
168 | console.error(`QueFlow Error:\nAn error occured from expression \`${currentMarkup}\``);
169 | }
170 |
171 | return out;
172 | }
173 |
174 |
175 | // Gets the attributes of a DOM element.
176 | function getAttributes(el) {
177 | return Array.from(el.attributes).map(({ nodeName, nodeValue }) => ({ attribute: nodeName, value: nodeValue }));
178 | }
179 |
180 |
181 | // Converts JSX/HTML string into plain HTML and Component data, handling placeholders.
182 | function jsxToHTML(jsx, instance, subId, flag) {
183 | const div = document.createElement("div");
184 | div.innerHTML = jsx;
185 | const data = [];
186 |
187 | try {
188 | const targetElements = div.querySelectorAll("*");
189 |
190 | for (const element of targetElements) {
191 | if (subId && !element.hasAttribute("data-sub_id")) {
192 | element.dataset.sub_id = subId;
193 | }
194 |
195 | data.push(...generateComponentData(element, hasChildren(element), instance));
196 | element.removeAttribute("innertext");
197 | }
198 | } catch (error) {
199 | console.error(`QueFlow Error:\nAn error in Component \`${instance.name || ""}\`:\n ${error}\n\nError sourced from: \`${jsx}\``);
200 | }
201 |
202 | const out = !flag ? evaluateTemplate(div.innerHTML, instance) : div.innerHTML;
203 |
204 | div.remove();
205 | return [out.replaceAll(" ", "\n"), data];
206 | }
207 |
208 |
209 | //Compares two objects and checks if their key-value pairs are strictly same
210 | function isSame(obj1, obj2) {
211 | const keys1 = Object.keys(obj1);
212 | const keys2 = Object.keys(obj2);
213 |
214 | if (keys1.length !== keys2.length) {
215 | return false;
216 | }
217 |
218 | for (const key of keys1) {
219 | if (obj1[key] !== obj2[key]) {
220 | return false;
221 | }
222 | }
223 |
224 | return true;
225 | }
226 |
227 | function convertDirective(attr, value, child) {
228 | const btw = b(value);
229 | switch (attr) {
230 | case 'q:show':
231 | child.removeAttribute(attr);
232 | return ['display', btw ? `{{ ${btw} ? 'block' : 'none' }}` : btw];
233 | break;
234 |
235 | default:
236 | return [attr, value];
237 | }
238 | }
239 |
240 | // Generates and returns dataQF property
241 | const generateComponentData = (child, isParent, instance) => {
242 | const arr = [];
243 | const attributes = getAttributes(child);
244 | let componentId = child.dataset.qfid;
245 | const { useStrict } = instance;
246 |
247 | // Precompute content injection for non-parents
248 | if (!isParent) {
249 | const contentKey = useStrict ? 'innerText' : 'innerHTML';
250 | const contentValue = useStrict ? child[contentKey] : child.innerHTML;
251 | attributes.push({ attribute: contentKey, value: contentValue });
252 | }
253 |
254 | // Cache expensive operations
255 | const processAttribute = ({ attribute, value }) => {
256 | value = value || '';
257 | [attribute, value] = convertDirective(attribute, value, child);
258 |
259 | const hasTemplate = value.includes('{{') && value.includes('}}');
260 | const processedValue = b(value).trim();
261 | const isGlobal = processedValue.startsWith('$');
262 | const evaluation = evaluateTemplate(value, instance);
263 |
264 | // Generate component ID once per element
265 | if (!componentId && hasTemplate) {
266 | child.dataset.qfid = `qf${counterQF}`;
267 | componentId = `qf${counterQF}`;
268 | counterQF++;
269 | }
270 |
271 | // DOM manipulation optimization
272 | const style = child.style;
273 | const styleProp = style[attribute];
274 | const isValidStyleProp = styleProp !== undefined;
275 | const requiresStyleKey = (
276 | (isValidStyleProp && attribute.toLowerCase() !== 'src') ||
277 | attribute === 'filter'
278 | );
279 |
280 | // Single DOM write path
281 | if (isValidStyleProp || styleProp === '') {
282 | style[attribute] = evaluation;
283 | if (attribute.toLowerCase() !== 'src') {
284 | child.removeAttribute(attribute);
285 | }
286 | }
287 | child[attribute] = evaluation;
288 |
289 | // Template tracking optimization
290 | if (hasTemplate && evaluation !== value) {
291 | return {
292 | template: value,
293 | key: requiresStyleKey ? `style.${attribute}` : attribute,
294 | qfid: componentId,
295 | isGlobal: isGlobal
296 | };
297 | }
298 | return null;
299 | };
300 |
301 | // Batch process attributes
302 | for (const entry of attributes) {
303 | const result = processAttribute(entry);
304 | if (result) {
305 | result.isGlobal ?
306 | globalStateDataQF.push(result) :
307 | arr.push(result);
308 | }
309 | }
310 |
311 | return arr;
312 | };
313 |
314 |
315 | // Function to convert an object into a CSS string
316 | function objToStyle(selector = "", obj = {}, alt = "", shouldSwitch) {
317 | let style = "";
318 | const isRegularRule = !alt.includes("@keyframes") && !alt.includes("@font-face");
319 |
320 | for (const key in obj) {
321 | const value = obj[key];
322 | const isFontFace = key.includes("@font-face");
323 |
324 | if (typeof value === "string") {
325 | const isMedia = alt.includes("@media");
326 | const sel = typeof value === "string" || isMedia ? selector : "";
327 |
328 | if (!shouldSwitch) {
329 | style += `\n${isRegularRule && !isFontFace ? sel : ""} ${key} {${value}}\n`;
330 | } else {
331 | style += `\n${key}${isRegularRule && !isMedia ? sel : ""} {\n${value}}\n`;
332 | }
333 | } else {
334 | style += `\n${key} {\n${objToStyle(selector, value, key)}\n}`;
335 | }
336 | }
337 |
338 | return style;
339 | }
340 |
341 | // Function to initiate the stylesheet
342 | function initiateStyleSheet(selector = "", instance = Object, shouldSwitch) {
343 | // Convert the instance's stylesheet into a CSS string
344 | let styles = objToStyle(selector, instance.stylesheet, "", shouldSwitch);
345 |
346 | // Append the styles to the stylesheet element
347 | (stylesheet.el).textContent += styles;
348 |
349 | // Append the stylesheet to the document head if not already appended
350 | if (!stylesheet.isAppended) {
351 | document.head.appendChild(stylesheet.el);
352 | }
353 | }
354 |
355 |
356 | function handleEventListener(parent, instance) {
357 | const children = parent.querySelectorAll("*");
358 |
359 | for (const child of children) {
360 | const subId = child.dataset.sub_id;
361 | const targetInstance = subId ? components.get(subId) : instance;
362 |
363 | if (targetInstance) {
364 | const attributes = getAttributes(child);
365 |
366 | for (const { attribute, value } of attributes) {
367 | if (attribute.startsWith("on")) {
368 | try {
369 | child[attribute] = Function("e", `const data = this.data; ${value}`).bind(targetInstance);
370 |
371 | } catch (e) {
372 | console.error(`QueFlow Error:\nFailed to add event listener on ${child.tagName} element:\n\nError from: \`${value}\``);
373 | }
374 | }
375 | }
376 | }
377 | child.removeAttribute("data-sub_id");
378 | }
379 | }
380 |
381 | function update(child, key, evaluated) {
382 | switch (key) {
383 | case 'q:exist':
384 | if (evaluated == "false") {
385 | removeEvents([child, ...child.querySelectorAll("*")]);
386 | child.remove();
387 | }
388 | break;
389 | default:
390 | if (key.indexOf("style.") > -1) {
391 | let sliced = key.slice(6);
392 | child.style[sliced] = evaluated;
393 | } else {
394 | if (!child?.getAttribute(key)) {
395 | if (child[key] != evaluated) child[key] = evaluated;
396 | } else {
397 | if (child.getAttribute(key) != evaluated) child.setAttribute(key, evaluated);
398 | }
399 | }
400 | }
401 | }
402 |
403 |
404 | // Checks if a template placeholder contains a key
405 | function needsUpdate(template, key) {
406 | if (!template.includes("{{") || !template.includes("}}")) return false;
407 |
408 | return (b(template).includes(key)) ? true : needsUpdate(template.replace("{{" + b(template) + "}}", b(template)), key);
409 | }
410 |
411 | // Updates a component based on changes made to it's data
412 | function updateComponent(ckey, obj, _new) {
413 | let dataQF;
414 | if (typeof obj === "boolean") {
415 | globalStateDataQF = filterNullElements(globalStateDataQF);
416 | dataQF = globalStateDataQF;
417 | } else {
418 | obj.dataQF = filterNullElements(obj.dataQF);
419 | dataQF = obj.dataQF;
420 | }
421 |
422 | for (let d of dataQF) {
423 | let { template, key, qfid } = d;
424 | const child = selectElement(qfid);
425 | if (child) {
426 | if (needsUpdate(template, ckey)) {
427 | let evaluated = evaluateTemplate(template, obj);
428 | key = (key === "class") ? "className" : key;
429 | update(child, key, evaluated);
430 | }
431 | }
432 | }
433 | }
434 |
435 | function renderTemplate(input, props, shouldSanitize) {
436 | const regex = /\{\{([^\{\}]+)\}\}/g; // Improved regex
437 |
438 | return input.replace(regex, (_, extracted) => { // Capture and use extracted
439 | const trimmed = extracted.trim();
440 | const value = props[trimmed];
441 |
442 | if (value === undefined || value === null) {
443 | return `{{ ${trimmed} }}`;
444 | }
445 |
446 | return shouldSanitize ? sanitizeString(value) : value;
447 | });
448 | }
449 |
450 | function initiateNuggets(markup, isNugget) {
451 | const nuggetRegex = /<([A-Z]\w*)\s*\{([\s\S]*?)\}\s*\/>/g;
452 |
453 | if (nuggetRegex.test(markup)) {
454 | markup = markup.replace(nuggetRegex, (match) => {
455 | const whiteSpaceIndex = match.indexOf(" "),
456 | name = match.slice(1, whiteSpaceIndex),
457 | data = match.slice(whiteSpaceIndex, -2).trim();
458 | let evaluated;
459 | try {
460 | const d = Function(`return ${data}`)(),
461 | instance = nuggets.get(name);
462 | if (instance) {
463 | evaluated = renderNugget(instance, d);
464 | } else {
465 | console.error(`QueFlow Error:\nNugget '${name}' is not defined, check whether '${name}' is correctly spelt or is defined.`);
466 | }
467 | } catch (e) {
468 | console.error(`QueFlow Error:\nAn error occured while rendering Nugget '${name}' \n ${e}, \n\nError sourced from: \n\`${match}\``);
469 | }
470 | return evaluated;
471 | });
472 | }
473 |
474 | return lintPlaceholders(markup, isNugget);
475 | }
476 |
477 | const initiateExtendedNuggets = (markup) => {
478 | const componentRegex = /<(\/?[A-Z]\w*)(\s*\(\{[\s\S]*?}\))?\s*>/g;
479 |
480 | // First pass: Convert component tags to HTML custom elements
481 | const convertedMarkup = markup.replace(componentRegex, (match, p1, p2) => {
482 | const isClosing = match.startsWith('');
483 | const tagName = p1.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '');
484 |
485 | if (isClosing) {
486 | return `${tagName.slice(2)}>`;
487 | }
488 |
489 | const attrs = (p2 || '')
490 | .replace(/\(\{/g, '{')
491 | .replace(/\}\)/g, '}')
492 | .replace(/"/g, '`');
493 |
494 | return `<${tagName} qf-attrs="${attrs}">`;
495 | });
496 |
497 | let finalMarkup = convertedMarkup;
498 | // Create temporary DOM
499 | const process = () => {
500 | const div = document.createElement("div");
501 | div.innerHTML = finalMarkup;
502 |
503 | // Process elements in reverse order (deepest first)
504 | const allElements = div.getElementsByTagName('*');
505 | const elements = Array.from(allElements).reverse();
506 |
507 | finalMarkup = div.innerHTML;
508 |
509 | for (const element of elements) {
510 | if (element.hasAttribute('qf-attrs')) {
511 | const originalTag = element.tagName.toLowerCase()
512 | .replace(/-([a-z])/g, (_, c) => c.toUpperCase())
513 | .replace(/^./, m => m.toUpperCase());
514 | try {
515 | const attrs = element.getAttribute('qf-attrs');
516 | const content = element.innerHTML;
517 | const originalHTML = element.outerHTML;
518 |
519 | const data = new Function(`return ${attrs}`)();
520 | const instance = nuggets.get(originalTag);
521 | if (instance) {
522 | const replacement = renderNugget(instance, data, true, content);
523 | finalMarkup = finalMarkup.split(originalHTML).join(replacement);
524 | } else {
525 | console.error(`QueFlow Error:\nNugget '${originalTag}' is not defined, check whether '${originalTag}' is correctly spelt or is defined.`);
526 | }
527 |
528 | } catch (e) {
529 | console.error(`QueFlow Error rendering ${originalTag}:\n${e}\nIn element:\n${originalHTML}`);
530 | }
531 | }
532 | }
533 | }
534 |
535 | process();
536 | process();
537 | return initiateNuggets(finalMarkup);
538 | };
539 |
540 | function initiateComponents(markup, isNugget) {
541 | const subRegex = new RegExp("<[A-Z]\\w+\/[>]", "g");
542 | markup = lintPlaceholders(markup, isNugget);
543 | if (subRegex.test(markup) && !isNugget) {
544 | markup = markup.replace(subRegex, (match) => {
545 | let evaluated, subName;
546 | try {
547 | subName = match.slice(1, -2);
548 | const instance = components.get(subName);
549 | if (instance) {
550 | evaluated = renderComponent(instance, subName);
551 | } else {
552 | console.error(`QueFlow Error:\nComponent '${subName}' is not defined, check whether '${subName}' is correctly spelt or is defined.`);
553 | }
554 |
555 | } catch (e) {
556 | console.error(`QueFlow Error:\nAn error occured while rendering Component '${subName}' \n ${e}, \n\nError sourced from: \n\`${match}\``);
557 | }
558 | return evaluated;
559 | });
560 | }
561 |
562 | markup = initiateNuggets(markup);
563 | markup = initiateExtendedNuggets(markup);
564 |
565 | return lintPlaceholders(markup, isNugget);
566 | }
567 |
568 |
569 | function g(str, className) {
570 | const div = document.createElement("div");
571 | div.innerHTML = str;
572 |
573 | div.querySelectorAll("*").forEach(child => {
574 | child.classList.add(className);
575 | });
576 |
577 | return div.innerHTML;
578 | }
579 |
580 | const lintPlaceholders = (html, isNugget) => {
581 | const attributeRegex = /\w+\s*=\s*\{\{[^}]+\}\}/g; // Simplified regex
582 | const eventRegex = /on\w+\s*=\s*\{\{(.*?)\}\}/gs;
583 |
584 | if (eventRegex.test(html) && !isNugget) {
585 | html = html.replace(eventRegex, (match) => {
586 | return match.replaceAll("'", "`").replace("{{", "'").replace(/}}$/, "'");
587 | });
588 | }
589 |
590 | if (attributeRegex.test(html)) {
591 | return html.replace(attributeRegex, (match) => {
592 | return match.replace("{{", '"{{').replace(/}}$/, '}}"');
593 | });
594 | }
595 | return html;
596 | };
597 |
598 | const removeEvents = (nodeList) => {
599 | nodeList.forEach((child) => {
600 | const attributes = getAttributes(child);
601 |
602 | for (var { attribute, value } of attributes) {
603 | if (attribute.slice(0, 2) === "on") {
604 | const fn = child[attribute];
605 | child.removeEventListener(attribute, fn);
606 | }
607 | }
608 | });
609 | }
610 |
611 | const renderComponent = (instance, name, flag) => {
612 | if (!instance.isMounted) {
613 | const id = typeof instance.element === 'string' ? instance.element : instance.element.id;
614 |
615 | let template = !flag ? ` ${(instance.template instanceof Function ? instance.template(instance.data) : instance.template)}
` : (instance.template instanceof Function ? instance.template(instance.data) : instance.template);
616 | template = handleRouter(template);
617 | template = initiateComponents(template);
618 |
619 | var rendered;
620 | if (!flag) {
621 | rendered = jsxToHTML(template, instance, name);
622 | // Initiates sub-component's stylesheet
623 | initiateStyleSheet(`#${id}`, instance);
624 | } else {
625 | initiateStyleSheet(`#${id}`, instance);
626 | rendered = jsxToHTML(template, instance, name);
627 | }
628 |
629 | instance.dataQF = rendered[1];
630 | instance.isMounted = true;
631 | return rendered[0];
632 | } else {
633 | return ""
634 | }
635 | }
636 |
637 | class App {
638 | constructor(selector = "", options = {}) {
639 | // Stores the element associated with a component
640 | this.element = typeof selector == "string" ? document.querySelector(selector) : selector;
641 |
642 | if (!this.element) throw new Error("QueFlow Error:\nElement selector '" + selector + "' is invalid");
643 | this.upTime = 0;
644 | // Creates a reactive signal for the component's data.
645 | this.data = createSignal(options.data, this);
646 |
647 | // Asigns the value of this.data' to _data
648 | let _data = this.data;
649 |
650 | // Stores the options provided to the component.
651 | this.options = options;
652 |
653 | // Stores the current 'freeze status' of the component
654 | this.isFrozen = false;
655 |
656 |
657 | // Stores the component's stylesheet
658 | this.stylesheet = options.stylesheet;
659 |
660 | // Stores the component's reactive elements data
661 | this.dataQF = [];
662 |
663 | this.onUpdate = options.onUpdate;
664 |
665 | this.created = options.created;
666 | this.run = options.run || (() => {});
667 |
668 | this.useStrict = Object.keys(options).includes('useStrict') ? options.useStrict : true;
669 |
670 | let id = this.element.id;
671 | if (!id) throw new Error("QueFlow Error:\nTo use component scoped stylesheets, component's mount node must have a valid id");
672 |
673 | // qà a component's stylesheet
674 | initiateStyleSheet(`#${id}`, this);
675 |
676 | // Defines properties for the component instance.
677 | Object.defineProperties(this, {
678 | template: { value: this.options.template },
679 | data: {
680 | // Getters and setters for 'data' property
681 | get: () => {
682 | return _data;
683 | },
684 | set: (data) => {
685 | // If 'data' is not same as 'this.data' and component is not frozem
686 | if (!isSame(data, this.data) && !this.isFrozen) {
687 | _data = createSignal(data, this);
688 | this.dataQF = filterNullElements(this.dataQF);
689 | this.render();
690 | }
691 | return true;
692 | },
693 | configurable: true
694 | }
695 | });
696 |
697 | if (this.created)
698 | this.created(this.data);
699 |
700 | }
701 |
702 | render() {
703 | let el = this.element;
704 | // Checks if the component's template is a string or a function.
705 | let template = this.template instanceof Function ? this.template(this.data) : this.template;
706 | template = handleRouter(template);
707 | // Initiate sub-components if they are available
708 | template = initiateComponents(template);
709 |
710 | // Convert template to html
711 | let rendered = jsxToHTML(template, this);
712 | // Set innerHTML attribute of component's element to the converted template
713 | el.innerHTML = rendered[0];
714 | currentComponent?.navigateFunc(currentComponent.data);
715 |
716 | this.dataQF = rendered[1];
717 | handleEventListener(el, this);
718 |
719 | for (const component of components) {
720 | const instance = component[1];
721 | if (instance.element) {
722 | strToEl(instance);
723 | }
724 | instance.run(instance.data);
725 | }
726 |
727 | this.run(this.data)
728 | }
729 |
730 | freeze() {
731 | // Freezes component
732 | this.isFrozen = true;
733 | }
734 |
735 | unfreeze() {
736 | // Unfreezes component
737 | this.isFrozen = false;
738 | }
739 |
740 | // removes the component's element from the DOM
741 | destroy() {
742 | const parent = [this.element, ...this.element.querySelectorAll('*')];
743 | removeEvents(parent);
744 |
745 | this.element.remove();
746 | }
747 | }
748 |
749 |
750 | class Component {
751 | constructor(name, options = {}) {
752 | if (name) {
753 | globalThis[name] = this;
754 | }
755 |
756 | this.name = name;
757 | this.template = options?.template;
758 | this.run = options.run || (() => {});
759 | this.navigateFunc = options.onNavigate || (() => {});
760 | if (!this.template) throw new Error("QueFlow Error:\nTemplate not provided for Component " + name);
761 |
762 | this.element = `qfEl${counterQF}`;
763 | counterQF++;
764 | this.isMounted = false;
765 | // Creates a reactive signal for the Component's data.
766 | this.data = createSignal(options.data, this);
767 |
768 | // Asigns the value of this.data' to _data
769 | let _data = this.data;
770 |
771 | // Stores the options provided to the component.
772 | this.options = options;
773 |
774 | // Stores the current 'freeze status' of the Component
775 | this.isFrozen = false;
776 |
777 | // Stores the id of the Component's mainelement
778 | this.elemId = "";
779 |
780 | this.created = options.created;
781 |
782 | // Stores the Component's stylesheet
783 | this.stylesheet = options.stylesheet;
784 |
785 | // Stores the Component's reactive elements data
786 | this.dataQF = [];
787 |
788 | this.onUpdate = options.onUpdate;
789 |
790 | this.useStrict = Object.keys(options).includes('useStrict') ? options.useStrict : true;
791 |
792 | // Defines data property for the Component instance.
793 | Object.defineProperties(this, {
794 | data: {
795 | // Getters and setters for 'data' property
796 | get: () => {
797 | return _data;
798 | },
799 | set: (data) => {
800 | // If 'data' is not same as 'this.data' and component is not frozem
801 | if (!isSame(data, this.data) && !this.isFrozen) {
802 | _data = createSignal(data, this);
803 | this.dataQF = filterNullElements(this.dataQF);
804 | renderComponent(this, this.name);
805 | }
806 | return true;
807 | },
808 | configurable: true,
809 | mutable: false
810 | }
811 | });
812 |
813 | if (this.created) this.created();
814 | components.set(name, this)
815 | }
816 |
817 | freeze() {
818 | // Freezes component
819 | this.isFrozen = true;
820 | }
821 |
822 | unfreeze() {
823 | // Unfreezes component
824 | this.isFrozen = false;
825 | }
826 |
827 | // Shows component
828 | show() {
829 | if (this.element.style.display !== 'block') {
830 | this.element.style.display = 'block'
831 | }
832 | }
833 | // Hides component
834 | hide() {
835 | if (this.element.style.display !== 'none') {
836 | this.element.style.display = 'none'
837 | }
838 | }
839 |
840 | mount() {
841 | if (!this.isMounted) {
842 | let rendered = renderComponent(this, this.name, true);
843 | this.element.innerHTML = rendered;
844 | handleEventListener(this.element, this);
845 | this.isMounted = true;
846 | }
847 | }
848 |
849 | // removes the component's element from the DOM
850 | destroy() {
851 | const all = [this.element, ...this.element.querySelectorAll('*')];
852 | // Removes event listeners attached to the component's element and its child nodes
853 | removeEvents(all);
854 |
855 | this.element.remove();
856 | }
857 | }
858 |
859 | function addIndexToTemplate(str, index) {
860 | const regex = /\{\{[^\{\{]+\}\}/g;
861 | const output = str.replace(regex, (match) => {
862 | const inner = b(match).trim();
863 | return `{{ this.data[${index}].${inner} }}`;
864 | });
865 | return lintPlaceholders(output);
866 | }
867 |
868 | function stringToDocumentFragment(htmlString = "") {
869 | /**
870 | * Converts an HTML string into a DocumentFragment.
871 | *
872 | * @param {string} htmlString - The HTML string to convert.
873 | * @returns {DocumentFragment} - The DocumentFragment containing the parsed HTML.
874 | */
875 | if (typeof htmlString !== 'string') {
876 | throw new TypeError('Input must be a string.');
877 | }
878 |
879 | const template = document.createElement('template');
880 | template.innerHTML = htmlString;
881 | return template.content.cloneNode(true); // Clone to avoid template content issues
882 | }
883 |
884 | class Atom {
885 | constructor(name, options, id) {
886 | globalThis[name] = this;
887 | this.element = id;
888 | this.name = name;
889 | this.template = options.template;
890 | this.stylesheet = options.stylesheet;
891 | initiateStyleSheet(`#${id}`, this);
892 | this.data = [];
893 | this.index = 0;
894 | this.dataQF = [];
895 | this.useStrict = true;
896 | this.isReactive = options.isReactive;
897 | }
898 |
899 | renderWith(data, position = "append") {
900 | if (typeof data !== "object") throw new Error(`Argument passed to '${this.name}.renderWith()' must be an object or an array.`);
901 | this.element = typeof this.element === "string" ? document.getElementById(this.element) : this.element;
902 | let rendered = "";
903 | if (this.isReactive) {
904 | const processData = (item) => {
905 | this.data.push(item);
906 | const template = typeof this.template === "function" ? this.template(item, this.index) : this.template;
907 | const indexedTemplate = addIndexToTemplate(template, this.index);
908 | const init = initiateComponents(indexedTemplate);
909 | rendered = position == "append" ? rendered + init : init + rendered;
910 | this.index++;
911 | };
912 |
913 | if (Array.isArray(data)) {
914 | data.forEach(processData);
915 | } else {
916 | processData(data);
917 | }
918 |
919 | const [htmlContent, componentData] = jsxToHTML(rendered, this, null, true);
920 | const template = stringToDocumentFragment(htmlContent);
921 | position == "append" ? this.element.appendChild(template) : this.element.prepend(template);
922 | handleEventListener(this.element, this);
923 | this.dataQF.push(...componentData);
924 | } else {
925 | let result = "";
926 | if (Array.isArray(data)) {
927 | data.forEach((item) => {
928 | const template = typeof this.template === "function" ? this.template(item, this.index) : this.template;
929 | result = position == "append" ? result + renderTemplate(template, item, true) : renderTemplate(template, item, true) + result;
930 | });
931 | } else {
932 | const template = typeof this.template === "function" ? this.template(data, this.index) : this.template;
933 | result = renderTemplate(template, data, true);
934 | }
935 | result = jsxToHTML(lintPlaceholders(result, true), this, null)[0];
936 | const template = stringToDocumentFragment(result);
937 | position == "append" ? this.element.appendChild(template) : this.element.prepend(template);
938 | handleEventListener(this.element, this);
939 | }
940 | }
941 |
942 | set(index, value) {
943 | if (!this.isReactive) throw new Error(`Cannot call 'set()' on Atom '${this.name}'.\n ${this.name} is not a reactive Atom`);
944 |
945 | const update = (indx, val) => {
946 | const keys = Object.keys(val);
947 | keys.forEach((key) => {
948 | if (this.data[indx][key] !== val[key]) {
949 | this.data[indx][key] = val[key];
950 | updateComponent(indx, this, val[key], true);
951 | }
952 | });
953 | }
954 |
955 | if (typeof index == "number") {
956 | update(index, value);
957 | } else if (Array.isArray(index)) {
958 | index.forEach((val, indx) => (this.data[indx]) && update(indx, val));
959 | } else {
960 | console.error(`First Argument passed to '${this.name}.set()' must either be a number or an array.`);
961 | }
962 | }
963 | }
964 |
965 | const renderNugget = (instance, data, isExtended, children) => {
966 | if (instance) {
967 | const className = instance.className;
968 | // Create a variable that holds the template
969 | let template = instance.template instanceof Function ? instance.template(data) : instance.template;
970 |
971 | if (isExtended) {
972 | template = template.replaceAll(">", children);
973 | }
974 |
975 | // Parse and initiate Nested Nuggets
976 | const initiated = initiateNuggets(template, true);
977 | // Render parsed html
978 | let rendered = renderTemplate(initiated, data);
979 | const html = g(rendered, className);
980 |
981 | if (!instance.stylesheetInitiated) {
982 | // Initiate stylesheet for instance
983 | initiateStyleSheet("." + className, instance, true);
984 | instance.stylesheetInitiated = true;
985 | }
986 |
987 | // Return processed html
988 | return html;
989 | }
990 | }
991 |
992 | class Nugget {
993 | /**
994 | * A class for creating reusable UI components
995 | * @param {Object} options An object containing all required options for the component
996 | */
997 |
998 | constructor(name, options = {}) {
999 | if (name) {
1000 | globalThis[name] = this;
1001 | }
1002 | // Stores instanc's stylesheet
1003 | this.stylesheet = options.stylesheet ?? {};
1004 |
1005 | // Create a property that generates a unique className for instance's parent element
1006 | this.className = `nugget${nuggetCounter}`;
1007 | // Increment the counterQF variable for later use
1008 | nuggetCounter++;
1009 | // Stores template
1010 | this.template = options.template;
1011 | this.stylesheetInitiated = false;
1012 | nuggets.set(name, this)
1013 | }
1014 | }
1015 |
1016 | globalThis.toPage = (path) => {
1017 | history.pushState({}, '', path);
1018 | loadComponent(path)
1019 | }
1020 |
1021 | const loadComponent = (path) => {
1022 | const len = routerObj.length;
1023 | let comp404 = '';
1024 |
1025 | const changeView = (name, title) => {
1026 | const instance = components.get(name);
1027 | currentComponent?.hide();
1028 | if (instance.isMounted) {
1029 | instance.show();
1030 | } else {
1031 | instance.mount();
1032 | instance.show();
1033 | }
1034 | document.title = title;
1035 | currentComponent = instance;
1036 | currentComponent.navigateFunc(currentComponent.data);
1037 | }
1038 |
1039 | for (let i = 0; i < len; i++) {
1040 | const { component, route, title } = routerObj[i];
1041 | if (route === "*") {
1042 | comp404 = component;
1043 | }
1044 | if (route === path) {
1045 | changeView(component, title);
1046 | break;
1047 | } else {
1048 | if (i === len - 1) {
1049 | changeView(comp404, title)
1050 | }
1051 | }
1052 | }
1053 | navigateFunc(path);
1054 | window.scrollTo(0, 0);
1055 | }
1056 |
1057 |
1058 | const Link = new Nugget('Link', {
1059 | template: (data) => {
1060 | const classN = data.class ? 'class={{ class }}' : '';
1061 | return `
1062 | ${ data.isBtn ? '{{ label }} ' : '{{ label }}' } `
1065 | }
1066 | })
1067 |
1068 | function handleRouter(input) {
1069 | const routerReg = /<(Router)\s*\{([\s\S]*?)\}\s*\/>/g;
1070 | let out = '',
1071 | computed = '';
1072 |
1073 | if (routerReg.test(input)) {
1074 | const extr = input.match(routerReg)[0],
1075 | whiteSpaceIndex = extr.indexOf(" "),
1076 | d = extr.slice(whiteSpaceIndex, -2).trim(),
1077 | path = window.location.pathname;
1078 | const data = Function(`return ${d}.routes`)(),
1079 | len = data.length;
1080 |
1081 | let comp404 = '',
1082 | isSet = false;
1083 |
1084 | computed = data.map(({ route, component }, i) => {
1085 | data[i].component = stringBetween(component, " <", "/>");
1086 |
1087 | const name = data[i].component;
1088 | const title = data[i].title;
1089 | if (!title) {
1090 | throw new Error(`QueFlow Router Error:\nTitle not set for component '${ name }'`)
1091 | }
1092 |
1093 | let instance = components.get(name);
1094 |
1095 | if (!instance) throw new Error(`\n\nQueFlow Router Error:\nAn error occured while rendering component '${name}'`);
1096 |
1097 | if (route === "*") {
1098 | comp404 = name;
1099 | }
1100 |
1101 | if (route === path) {
1102 | isSet = true;
1103 | currentComponent = instance;
1104 | document.title = title;
1105 | return renderComponent(instance, name);
1106 | } else {
1107 | if (i === len - 1 && !isSet) {
1108 | instance = components.get(comp404);
1109 | currentComponent = instance;
1110 | document.title = title;
1111 | return renderComponent(instance, comp404);
1112 | } else {
1113 | const id = instance.element;
1114 | return `
`;
1115 | }
1116 | }
1117 | }).join('');
1118 | routerObj = data;
1119 | out = input.replace(extr, computed);
1120 | } else {
1121 | return input;
1122 | }
1123 |
1124 | window.addEventListener('popstate', () => {
1125 | const path = window.location.pathname;
1126 | loadComponent(path);
1127 | });
1128 |
1129 | return out;
1130 | }
1131 |
1132 | const onNavigate = (func, instance) => {
1133 | navigateFunc = func.bind(instance);
1134 | }
1135 |
1136 | export {
1137 | App,
1138 | Component,
1139 | Nugget,
1140 | Atom,
1141 | onNavigate,
1142 | globalState
1143 | };
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "queflow",
3 | "version": "4.2.6",
4 | "description": "A simple JavaScript library for building performant web apps.",
5 | "keywords": ["queflow", "framework", "template", "UI"],
6 | "main": "./lib/queflow.js",
7 | "scripts": {
8 | "test": "echo \"Error: no test specified\" && exit 1"
9 | },
10 | "repository": {
11 | "type": "git",
12 | "url": "git+https://github.com/dayson9/queflowjs.git"
13 | },
14 | "homepage": "https://queflowjs.vercel.app",
15 | "author": "dayson9",
16 | "license": "MIT"
17 | }
--------------------------------------------------------------------------------