odac 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/.editorconfig +21 -0
  2. package/.github/workflows/auto-pr-description.yml +49 -0
  3. package/.github/workflows/release.yml +32 -0
  4. package/.github/workflows/test-coverage.yml +58 -0
  5. package/.husky/pre-commit +2 -0
  6. package/.kiro/steering/code-style.md +56 -0
  7. package/.kiro/steering/product.md +20 -0
  8. package/.kiro/steering/structure.md +77 -0
  9. package/.kiro/steering/tech.md +87 -0
  10. package/.prettierrc +10 -0
  11. package/.releaserc.js +134 -0
  12. package/AGENTS.md +84 -0
  13. package/CHANGELOG.md +181 -0
  14. package/CODE_OF_CONDUCT.md +83 -0
  15. package/CONTRIBUTING.md +63 -0
  16. package/LICENSE +661 -0
  17. package/README.md +57 -0
  18. package/SECURITY.md +26 -0
  19. package/bin/candy +10 -0
  20. package/bin/candypack +10 -0
  21. package/cli/index.js +3 -0
  22. package/cli/src/Cli.js +348 -0
  23. package/cli/src/Connector.js +93 -0
  24. package/cli/src/Monitor.js +416 -0
  25. package/core/Candy.js +87 -0
  26. package/core/Commands.js +239 -0
  27. package/core/Config.js +1094 -0
  28. package/core/Lang.js +52 -0
  29. package/core/Log.js +43 -0
  30. package/core/Process.js +26 -0
  31. package/docs/backend/01-overview/01-whats-in-the-candy-box.md +9 -0
  32. package/docs/backend/01-overview/02-super-handy-helper-functions.md +9 -0
  33. package/docs/backend/01-overview/03-development-server.md +79 -0
  34. package/docs/backend/02-structure/01-typical-project-layout.md +39 -0
  35. package/docs/backend/03-config/00-configuration-overview.md +214 -0
  36. package/docs/backend/03-config/01-database-connection.md +60 -0
  37. package/docs/backend/03-config/02-static-route-mapping-optional.md +20 -0
  38. package/docs/backend/03-config/03-request-timeout.md +11 -0
  39. package/docs/backend/03-config/04-environment-variables.md +227 -0
  40. package/docs/backend/03-config/05-early-hints.md +352 -0
  41. package/docs/backend/04-routing/01-basic-page-routes.md +28 -0
  42. package/docs/backend/04-routing/02-controller-less-view-routes.md +43 -0
  43. package/docs/backend/04-routing/03-api-and-data-routes.md +20 -0
  44. package/docs/backend/04-routing/04-authentication-aware-routes.md +48 -0
  45. package/docs/backend/04-routing/05-advanced-routing.md +14 -0
  46. package/docs/backend/04-routing/06-error-pages.md +101 -0
  47. package/docs/backend/04-routing/07-cron-jobs.md +149 -0
  48. package/docs/backend/05-controllers/01-how-to-build-a-controller.md +17 -0
  49. package/docs/backend/05-controllers/02-your-trusty-candy-assistant.md +20 -0
  50. package/docs/backend/05-controllers/03-controller-classes.md +93 -0
  51. package/docs/backend/05-forms/01-custom-forms.md +395 -0
  52. package/docs/backend/05-forms/02-automatic-database-insert.md +297 -0
  53. package/docs/backend/06-request-and-response/01-the-request-object-what-is-the-user-asking-for.md +96 -0
  54. package/docs/backend/06-request-and-response/02-sending-a-response-replying-to-the-user.md +40 -0
  55. package/docs/backend/07-views/01-the-view-directory.md +73 -0
  56. package/docs/backend/07-views/02-rendering-a-view.md +179 -0
  57. package/docs/backend/07-views/03-template-syntax.md +181 -0
  58. package/docs/backend/07-views/03-variables.md +328 -0
  59. package/docs/backend/07-views/04-request-data.md +231 -0
  60. package/docs/backend/07-views/05-conditionals.md +290 -0
  61. package/docs/backend/07-views/06-loops.md +353 -0
  62. package/docs/backend/07-views/07-translations.md +358 -0
  63. package/docs/backend/07-views/08-backend-javascript.md +398 -0
  64. package/docs/backend/07-views/09-comments.md +297 -0
  65. package/docs/backend/08-database/01-database-connection.md +99 -0
  66. package/docs/backend/08-database/02-using-mysql.md +322 -0
  67. package/docs/backend/09-validation/01-the-validator-service.md +424 -0
  68. package/docs/backend/10-authentication/01-user-logins-with-authjs.md +53 -0
  69. package/docs/backend/10-authentication/02-foiling-villains-with-csrf-protection.md +55 -0
  70. package/docs/backend/10-authentication/03-register.md +134 -0
  71. package/docs/backend/10-authentication/04-candy-register-forms.md +676 -0
  72. package/docs/backend/10-authentication/05-session-management.md +159 -0
  73. package/docs/backend/10-authentication/06-candy-login-forms.md +596 -0
  74. package/docs/backend/11-mail/01-the-mail-service.md +42 -0
  75. package/docs/backend/12-streaming/01-streaming-overview.md +300 -0
  76. package/docs/backend/13-utilities/01-candy-var.md +504 -0
  77. package/docs/frontend/01-overview/01-introduction.md +146 -0
  78. package/docs/frontend/02-ajax-navigation/01-quick-start.md +608 -0
  79. package/docs/frontend/02-ajax-navigation/02-configuration.md +370 -0
  80. package/docs/frontend/02-ajax-navigation/03-advanced-usage.md +519 -0
  81. package/docs/frontend/03-forms/01-form-handling.md +420 -0
  82. package/docs/frontend/04-api-requests/01-get-post.md +443 -0
  83. package/docs/frontend/05-streaming/01-client-streaming.md +163 -0
  84. package/docs/index.json +452 -0
  85. package/docs/server/01-installation/01-quick-install.md +19 -0
  86. package/docs/server/01-installation/02-manual-installation-via-npm.md +9 -0
  87. package/docs/server/02-get-started/01-core-concepts.md +7 -0
  88. package/docs/server/02-get-started/02-basic-commands.md +57 -0
  89. package/docs/server/02-get-started/03-cli-reference.md +276 -0
  90. package/docs/server/02-get-started/04-cli-quick-reference.md +102 -0
  91. package/docs/server/03-service/01-start-a-new-service.md +57 -0
  92. package/docs/server/03-service/02-delete-a-service.md +48 -0
  93. package/docs/server/04-web/01-create-a-website.md +36 -0
  94. package/docs/server/04-web/02-list-websites.md +9 -0
  95. package/docs/server/04-web/03-delete-a-website.md +29 -0
  96. package/docs/server/05-subdomain/01-create-a-subdomain.md +32 -0
  97. package/docs/server/05-subdomain/02-list-subdomains.md +33 -0
  98. package/docs/server/05-subdomain/03-delete-a-subdomain.md +41 -0
  99. package/docs/server/06-ssl/01-renew-an-ssl-certificate.md +34 -0
  100. package/docs/server/07-mail/01-create-a-mail-account.md +23 -0
  101. package/docs/server/07-mail/02-delete-a-mail-account.md +20 -0
  102. package/docs/server/07-mail/03-list-mail-accounts.md +20 -0
  103. package/docs/server/07-mail/04-change-account-password.md +23 -0
  104. package/eslint.config.mjs +120 -0
  105. package/framework/index.js +4 -0
  106. package/framework/src/Auth.js +309 -0
  107. package/framework/src/Candy.js +81 -0
  108. package/framework/src/Config.js +79 -0
  109. package/framework/src/Env.js +60 -0
  110. package/framework/src/Lang.js +57 -0
  111. package/framework/src/Mail.js +83 -0
  112. package/framework/src/Mysql.js +575 -0
  113. package/framework/src/Request.js +301 -0
  114. package/framework/src/Route/Cron.js +128 -0
  115. package/framework/src/Route/Internal.js +439 -0
  116. package/framework/src/Route.js +455 -0
  117. package/framework/src/Server.js +15 -0
  118. package/framework/src/Stream.js +163 -0
  119. package/framework/src/Token.js +37 -0
  120. package/framework/src/Validator.js +271 -0
  121. package/framework/src/Var.js +211 -0
  122. package/framework/src/View/EarlyHints.js +190 -0
  123. package/framework/src/View/Form.js +600 -0
  124. package/framework/src/View.js +513 -0
  125. package/framework/web/candy.js +838 -0
  126. package/jest.config.js +22 -0
  127. package/locale/de-DE.json +80 -0
  128. package/locale/en-US.json +79 -0
  129. package/locale/es-ES.json +80 -0
  130. package/locale/fr-FR.json +80 -0
  131. package/locale/pt-BR.json +80 -0
  132. package/locale/ru-RU.json +80 -0
  133. package/locale/tr-TR.json +85 -0
  134. package/locale/zh-CN.json +80 -0
  135. package/package.json +86 -0
  136. package/server/index.js +5 -0
  137. package/server/src/Api.js +88 -0
  138. package/server/src/DNS.js +940 -0
  139. package/server/src/Hub.js +535 -0
  140. package/server/src/Mail.js +571 -0
  141. package/server/src/SSL.js +180 -0
  142. package/server/src/Server.js +27 -0
  143. package/server/src/Service.js +248 -0
  144. package/server/src/Subdomain.js +64 -0
  145. package/server/src/Web/Firewall.js +170 -0
  146. package/server/src/Web/Proxy.js +134 -0
  147. package/server/src/Web.js +451 -0
  148. package/server/src/mail/imap.js +1091 -0
  149. package/server/src/mail/server.js +32 -0
  150. package/server/src/mail/smtp.js +786 -0
  151. package/test/cli/Cli.test.js +36 -0
  152. package/test/core/Candy.test.js +234 -0
  153. package/test/core/Commands.test.js +538 -0
  154. package/test/core/Config.test.js +1435 -0
  155. package/test/core/Lang.test.js +250 -0
  156. package/test/core/Process.test.js +156 -0
  157. package/test/framework/Route.test.js +239 -0
  158. package/test/framework/View/EarlyHints.test.js +282 -0
  159. package/test/scripts/check-coverage.js +132 -0
  160. package/test/server/Api.test.js +647 -0
  161. package/test/server/Client.test.js +338 -0
  162. package/test/server/DNS.test.js +2050 -0
  163. package/test/server/DNS.test.js.bak +2084 -0
  164. package/test/server/Log.test.js +73 -0
  165. package/test/server/Mail.account.test_.js +460 -0
  166. package/test/server/Mail.init.test_.js +411 -0
  167. package/test/server/Mail.test_.js +1340 -0
  168. package/test/server/SSL.test_.js +1491 -0
  169. package/test/server/Server.test.js +765 -0
  170. package/test/server/Service.test_.js +1127 -0
  171. package/test/server/Subdomain.test.js +440 -0
  172. package/test/server/Web/Firewall.test.js +175 -0
  173. package/test/server/Web.test_.js +1562 -0
  174. package/test/server/__mocks__/acme-client.js +17 -0
  175. package/test/server/__mocks__/bcrypt.js +50 -0
  176. package/test/server/__mocks__/child_process.js +389 -0
  177. package/test/server/__mocks__/crypto.js +432 -0
  178. package/test/server/__mocks__/fs.js +450 -0
  179. package/test/server/__mocks__/globalCandy.js +227 -0
  180. package/test/server/__mocks__/http-proxy.js +105 -0
  181. package/test/server/__mocks__/http.js +575 -0
  182. package/test/server/__mocks__/https.js +272 -0
  183. package/test/server/__mocks__/index.js +249 -0
  184. package/test/server/__mocks__/mail/server.js +100 -0
  185. package/test/server/__mocks__/mail/smtp.js +31 -0
  186. package/test/server/__mocks__/mailparser.js +81 -0
  187. package/test/server/__mocks__/net.js +369 -0
  188. package/test/server/__mocks__/node-forge.js +328 -0
  189. package/test/server/__mocks__/os.js +320 -0
  190. package/test/server/__mocks__/path.js +291 -0
  191. package/test/server/__mocks__/selfsigned.js +8 -0
  192. package/test/server/__mocks__/server/src/mail/server.js +100 -0
  193. package/test/server/__mocks__/server/src/mail/smtp.js +31 -0
  194. package/test/server/__mocks__/smtp-server.js +106 -0
  195. package/test/server/__mocks__/sqlite3.js +394 -0
  196. package/test/server/__mocks__/testFactories.js +299 -0
  197. package/test/server/__mocks__/testHelpers.js +363 -0
  198. package/test/server/__mocks__/tls.js +229 -0
  199. package/watchdog/index.js +3 -0
  200. package/watchdog/src/Watchdog.js +156 -0
  201. package/web/config.json +5 -0
  202. package/web/controller/page/about.js +27 -0
  203. package/web/controller/page/index.js +34 -0
  204. package/web/package.json +18 -0
  205. package/web/public/assets/css/style.css +1835 -0
  206. package/web/public/assets/js/app.js +96 -0
  207. package/web/route/www.js +19 -0
  208. package/web/skeleton/main.html +22 -0
  209. package/web/view/content/about.html +65 -0
  210. package/web/view/content/home.html +205 -0
  211. package/web/view/footer/main.html +11 -0
  212. package/web/view/head/main.html +5 -0
  213. package/web/view/header/main.html +14 -0
@@ -0,0 +1,838 @@
1
+ class candy {
2
+ actions = {}
3
+ #data = null
4
+ fn = {}
5
+ #page = null
6
+ #token = {hash: [], data: false}
7
+ #formSubmitHandlers = new Map()
8
+ #loader = {elements: {}, callback: null}
9
+ #isNavigating = false
10
+
11
+ constructor() {
12
+ this.#data = this.data()
13
+ }
14
+
15
+ #ajax(options) {
16
+ const {
17
+ url,
18
+ type = 'GET',
19
+ headers = {},
20
+ data = null,
21
+ dataType = 'text',
22
+ success = () => {},
23
+ error = () => {},
24
+ complete = () => {},
25
+ contentType = 'application/x-www-form-urlencoded; charset=UTF-8',
26
+ xhr: xhrFactory
27
+ } = options
28
+
29
+ const xhr = xhrFactory ? xhrFactory() : new XMLHttpRequest()
30
+
31
+ xhr.open(type, url, true)
32
+
33
+ Object.keys(headers).forEach(key => {
34
+ xhr.setRequestHeader(key, headers[key])
35
+ })
36
+
37
+ if (contentType && !(data instanceof FormData)) {
38
+ xhr.setRequestHeader('Content-Type', contentType)
39
+ }
40
+
41
+ xhr.onload = () => {
42
+ if (xhr.status >= 200 && xhr.status < 300) {
43
+ let responseData = xhr.responseText
44
+ if (dataType === 'json') {
45
+ try {
46
+ responseData = JSON.parse(responseData)
47
+ } catch (e) {
48
+ console.error('JSON parse error:', e)
49
+ error(xhr, 'parseerror', e)
50
+ return
51
+ }
52
+ }
53
+
54
+ document.dispatchEvent(
55
+ new CustomEvent('candy:ajaxSuccess', {
56
+ detail: {response: responseData, status: xhr.statusText, xhr, requestUrl: url}
57
+ })
58
+ )
59
+
60
+ success(responseData, xhr.statusText, xhr)
61
+ } else {
62
+ error(xhr, xhr.statusText)
63
+ }
64
+ }
65
+
66
+ xhr.onerror = () => error(xhr, 'error')
67
+ xhr.onloadend = () => complete()
68
+ xhr.send(data)
69
+ }
70
+
71
+ #fade(element, type, duration = 400, callback) {
72
+ const isIn = type === 'in'
73
+ const startOpacity = isIn ? 0 : 1
74
+ const endOpacity = isIn ? 1 : 0
75
+
76
+ element.style.opacity = startOpacity
77
+ if (isIn) {
78
+ element.style.display = 'block'
79
+ }
80
+
81
+ let startTime = null
82
+
83
+ const animate = currentTime => {
84
+ if (!startTime) startTime = currentTime
85
+ const progress = currentTime - startTime
86
+ const opacity = startOpacity + (endOpacity - startOpacity) * Math.min(progress / duration, 1)
87
+ element.style.opacity = opacity
88
+
89
+ if (progress < duration) {
90
+ requestAnimationFrame(animate)
91
+ } else {
92
+ if (!isIn) {
93
+ element.style.display = 'none'
94
+ }
95
+ if (callback) callback()
96
+ }
97
+ }
98
+ requestAnimationFrame(animate)
99
+ }
100
+
101
+ #fadeIn(element, duration, callback) {
102
+ this.#fade(element, 'in', duration, callback)
103
+ }
104
+
105
+ #fadeOut(element, duration, callback) {
106
+ this.#fade(element, 'out', duration, callback)
107
+ }
108
+
109
+ #on(element, event, selector, handler) {
110
+ element.addEventListener(event, e => {
111
+ let target = e.target.closest(selector)
112
+ if (target) {
113
+ handler.call(target, e)
114
+ }
115
+ })
116
+ }
117
+
118
+ #serialize(form) {
119
+ const params = []
120
+ form.querySelectorAll('input, select, textarea').forEach(el => {
121
+ if (el.name && !el.disabled) {
122
+ if (el.type === 'checkbox' || el.type === 'radio') {
123
+ if (el.checked) {
124
+ params.push(`${encodeURIComponent(el.name)}=${encodeURIComponent(el.value)}`)
125
+ }
126
+ } else if (el.tagName.toLowerCase() === 'select' && el.multiple) {
127
+ Array.from(el.options).forEach(option => {
128
+ if (option.selected) {
129
+ params.push(`${encodeURIComponent(el.name)}=${encodeURIComponent(option.value)}`)
130
+ }
131
+ })
132
+ } else {
133
+ params.push(`${encodeURIComponent(el.name)}=${encodeURIComponent(el.value)}`)
134
+ }
135
+ }
136
+ })
137
+ return params.join('&')
138
+ }
139
+
140
+ action(obj) {
141
+ if (obj.function) for (let func in obj.function) this.fn[func] = obj.function[func]
142
+
143
+ // Handle navigate configuration
144
+ if (obj.navigate !== undefined && obj.navigate !== false) {
145
+ let selector, elements, callback
146
+
147
+ // Minimal: navigate: 'main'
148
+ if (typeof obj.navigate === 'string') {
149
+ selector = 'a[href^="/"]:not([data-navigate="false"]):not(.no-navigate)'
150
+ elements = {content: obj.navigate}
151
+ callback = null
152
+ }
153
+ // Medium/Advanced: navigate: {...}
154
+ else if (typeof obj.navigate === 'object') {
155
+ // Determine base selector
156
+ let baseSelector
157
+ if (obj.navigate.links) {
158
+ baseSelector = obj.navigate.links
159
+ } else if (obj.navigate.selector) {
160
+ baseSelector = obj.navigate.selector
161
+ } else {
162
+ baseSelector = 'a[href^="/"]' // Default: all internal links
163
+ }
164
+
165
+ // Add exclusions to selector
166
+ selector = `${baseSelector}:not([data-navigate="false"]):not(.no-navigate)`
167
+
168
+ // Determine elements to update
169
+ if (obj.navigate.update) {
170
+ if (typeof obj.navigate.update === 'string') {
171
+ elements = {content: obj.navigate.update}
172
+ } else {
173
+ elements = obj.navigate.update
174
+ }
175
+ } else if (obj.navigate.elements) {
176
+ elements = obj.navigate.elements
177
+ } else {
178
+ elements = {content: 'main'} // Default
179
+ }
180
+
181
+ // Determine callback
182
+ callback = obj.navigate.on || obj.navigate.callback || null
183
+ }
184
+ // Boolean: navigate: true
185
+ else if (obj.navigate === true) {
186
+ selector = 'a[href^="/"]:not([data-navigate="false"]):not(.no-navigate)'
187
+ elements = {content: 'main'}
188
+ callback = null
189
+ }
190
+
191
+ // Initialize loader after DOM is ready
192
+ if (document.readyState === 'loading') {
193
+ document.addEventListener('DOMContentLoaded', () => {
194
+ this.loader(selector, elements, callback)
195
+ })
196
+ } else {
197
+ this.loader(selector, elements, callback)
198
+ }
199
+ }
200
+
201
+ if (obj.start) document.addEventListener('DOMContentLoaded', () => obj.start())
202
+ if (obj.load) {
203
+ if (!this.actions.load) this.actions.load = []
204
+ this.actions.load.push(obj.load)
205
+ document.addEventListener('DOMContentLoaded', () => obj.load())
206
+ }
207
+ if (obj.page) {
208
+ if (!this.actions.page) this.actions.page = {}
209
+ for (let page in obj.page) {
210
+ if (!this.actions.page[page]) this.actions.page[page] = []
211
+ this.actions.page[page].push(obj.page[page])
212
+ if (this.page() == page) document.addEventListener('DOMContentLoaded', () => obj.page[page]())
213
+ }
214
+ }
215
+ if (obj.interval) {
216
+ if (!this.actions.interval) this.actions.interval = {}
217
+ for (let interval in obj.interval) {
218
+ this.actions.interval[interval] = obj.interval[interval]
219
+ if (obj.interval[interval].page && obj.interval[interval].page != this.page()) continue
220
+ this.actions.interval[interval]._ = setInterval(obj.interval[interval].function, obj.interval[interval].interval ?? 1000)
221
+ }
222
+ }
223
+ for (let key in obj) {
224
+ if (['function', 'start', 'load', 'page', 'interval', 'navigate'].includes(key)) continue
225
+ for (let key2 in obj[key]) {
226
+ if (typeof obj[key][key2] == 'function') {
227
+ this.#on(document, key, key2, obj[key][key2])
228
+ } else {
229
+ let func = ''
230
+ let split = ''
231
+ if (obj[key][key2].includes('.')) split = '.'
232
+ else if (obj[key][key2].includes('#')) split = '#'
233
+ else if (obj[key][key2].includes(' ')) split = ' '
234
+ func = split != '' ? obj[key][key2].split(split) : [obj[key][key2]]
235
+ if (func != '') {
236
+ let getfunc = obj
237
+ func.forEach(function (item) {
238
+ getfunc = getfunc[item] !== undefined ? getfunc[item] : getfunc[split + item]
239
+ })
240
+ this.#on(document, key, key2, getfunc)
241
+ }
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ client() {
248
+ if (!document.cookie.includes('candy_client=')) return null
249
+ return document.cookie.split('candy_client=')[1].split(';')[0]
250
+ }
251
+
252
+ data() {
253
+ if (this.#data) return this.#data
254
+ if (!document.cookie.includes('candy_data=')) return null
255
+ return JSON.parse(unescape(document.cookie.split('candy_data=')[1].split(';')[0]))
256
+ }
257
+
258
+ form(obj, callback) {
259
+ if (typeof obj != 'object') obj = {form: obj}
260
+ const formSelector = obj.form
261
+
262
+ if (this.#formSubmitHandlers.has(formSelector)) {
263
+ const oldHandler = this.#formSubmitHandlers.get(formSelector)
264
+ document.removeEventListener('submit', oldHandler)
265
+ }
266
+
267
+ const handler = e => {
268
+ const formElement = e.target.closest(formSelector)
269
+ if (!formElement) return
270
+
271
+ e.preventDefault()
272
+
273
+ const inputs = formElement.querySelectorAll('input:not([type="hidden"]), textarea, select')
274
+ let isValid = true
275
+ let firstInvalidInput = null
276
+
277
+ const showError = (input, errorType) => {
278
+ isValid = false
279
+ firstInvalidInput = input
280
+
281
+ if (input.type !== 'checkbox' && input.type !== 'radio') {
282
+ input.style.borderColor = '#dc3545'
283
+ }
284
+
285
+ const customMessage = input.getAttribute(`data-error-${errorType}`)
286
+ if (customMessage) {
287
+ let errorSpan = formElement.querySelector(`[candy-form-error="${input.name}"]`)
288
+
289
+ if (!errorSpan) {
290
+ errorSpan = document.createElement('span')
291
+ errorSpan.setAttribute('candy-form-error', input.name)
292
+
293
+ if ((input.type === 'checkbox' || input.type === 'radio') && input.id) {
294
+ const label = formElement.querySelector(`label[for="${input.id}"]`)
295
+ if (label) {
296
+ label.parentNode.insertBefore(errorSpan, label.nextSibling)
297
+ } else {
298
+ input.parentNode.insertBefore(errorSpan, input.nextSibling)
299
+ }
300
+ } else {
301
+ input.parentNode.insertBefore(errorSpan, input.nextSibling)
302
+ }
303
+ }
304
+
305
+ errorSpan.textContent = customMessage
306
+ errorSpan.style.cssText = 'display:block;color:#dc3545;font-size:0.875rem;margin-top:0.25rem'
307
+ }
308
+ }
309
+
310
+ for (const input of inputs) {
311
+ input.style.borderColor = ''
312
+ const errorSpan = formElement.querySelector(`[candy-form-error="${input.name}"]`)
313
+ if (errorSpan) {
314
+ errorSpan.style.display = 'none'
315
+ errorSpan.textContent = ''
316
+ }
317
+
318
+ if (input.hasAttribute('required')) {
319
+ const isEmpty = input.type === 'checkbox' || input.type === 'radio' ? !input.checked : !input.value.trim()
320
+ if (isEmpty) {
321
+ showError(input, 'required')
322
+ break
323
+ }
324
+ }
325
+
326
+ if (input.hasAttribute('minlength') && input.value && input.value.trim().length < parseInt(input.getAttribute('minlength'))) {
327
+ showError(input, 'minlength')
328
+ break
329
+ }
330
+
331
+ if (input.hasAttribute('maxlength') && input.value && input.value.trim().length > parseInt(input.getAttribute('maxlength'))) {
332
+ showError(input, 'maxlength')
333
+ break
334
+ }
335
+
336
+ if (input.hasAttribute('pattern') && input.value) {
337
+ const trimmedValue = input.value.trim()
338
+ const pattern = input.getAttribute('pattern')
339
+ if (!new RegExp(pattern).test(trimmedValue)) {
340
+ showError(input, 'pattern')
341
+ break
342
+ }
343
+ }
344
+
345
+ if (input.type === 'email' && input.value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.value.trim())) {
346
+ showError(input, 'email')
347
+ break
348
+ }
349
+ }
350
+
351
+ if (!isValid) {
352
+ if (firstInvalidInput) firstInvalidInput.focus()
353
+ return
354
+ }
355
+
356
+ let actions = this.actions
357
+ if (
358
+ actions.candy &&
359
+ actions.candy.form &&
360
+ actions.candy.form.input &&
361
+ actions.candy.form.input.class &&
362
+ actions.candy.form.input.class.invalid
363
+ ) {
364
+ const invalidClass = actions.candy.form.input.class.invalid
365
+ formElement
366
+ .querySelectorAll(`select.${invalidClass},input.${invalidClass},textarea.${invalidClass}`)
367
+ .forEach(el => el.classList.remove(invalidClass))
368
+ }
369
+
370
+ if (obj.messages !== false) {
371
+ if (obj.messages == undefined || obj.messages == true || obj.messages.includes('error')) {
372
+ formElement.querySelectorAll('*[candy-form-error]').forEach(el => (el.style.display = 'none'))
373
+ }
374
+ if (obj.messages == undefined || obj.messages == true || obj.messages.includes('success')) {
375
+ formElement.querySelectorAll('*[candy-form-success]').forEach(el => (el.style.display = 'none'))
376
+ }
377
+ }
378
+
379
+ let datastring, cache, contentType, processData
380
+ if (formElement.querySelector('input[type=file]')) {
381
+ datastring = new FormData(formElement)
382
+ datastring.append('token', this.token())
383
+ cache = false
384
+ contentType = false
385
+ processData = false
386
+ } else {
387
+ datastring = this.#serialize(formElement) + '&_token=' + this.token()
388
+ cache = true
389
+ contentType = 'application/x-www-form-urlencoded; charset=UTF-8'
390
+ processData = true
391
+ }
392
+
393
+ const submitButtons = formElement.querySelectorAll('button[type="submit"], input[type="submit"]')
394
+ submitButtons.forEach(btn => {
395
+ btn.disabled = true
396
+ const loadingText = btn.getAttribute('data-loading-text')
397
+ if (loadingText) {
398
+ btn.setAttribute('data-original-text', btn.textContent)
399
+ btn.textContent = loadingText
400
+ }
401
+ })
402
+
403
+ formElement.querySelectorAll('input:not([type="hidden"]), textarea, select').forEach(el => (el.disabled = true))
404
+
405
+ this.#ajax({
406
+ type: formElement.getAttribute('method'),
407
+ url: formElement.getAttribute('action'),
408
+ data: datastring,
409
+ dataType: 'json',
410
+ contentType: contentType,
411
+ processData: processData,
412
+ cache: cache,
413
+ success: data => {
414
+ if (!data.result) return false
415
+ if (obj.messages == undefined || obj.messages) {
416
+ if (data.result.success && (obj.messages == undefined || obj.messages.includes('success') || obj.messages == true)) {
417
+ const successEl = formElement.querySelector('*[candy-form-success]')
418
+ if (successEl) {
419
+ successEl.innerHTML = data.result.message
420
+ this.#fadeIn(successEl)
421
+ } else {
422
+ formElement.insertAdjacentHTML('beforeend', `<span candy-form-success="${obj.form}">${data.result.message}</span>`)
423
+ }
424
+ } else if (!data.result.success && data.errors) {
425
+ Object.entries(data.errors).forEach(([name, message]) => {
426
+ if (message) {
427
+ let errorEl = formElement.querySelector(`[candy-form-error="${name}"]`)
428
+ if (errorEl) {
429
+ errorEl.textContent = message
430
+ errorEl.style.cssText = 'display:block;color:#dc3545;font-size:0.875rem;margin-top:0.25rem'
431
+ } else {
432
+ const inputEl = formElement.querySelector(`*[name="${name}"]`)
433
+ if (inputEl) {
434
+ errorEl = document.createElement('span')
435
+ errorEl.setAttribute('candy-form-error', name)
436
+ errorEl.textContent = message
437
+ errorEl.style.cssText = 'display:block;color:#dc3545;font-size:0.875rem;margin-top:0.25rem'
438
+
439
+ if ((inputEl.type === 'checkbox' || inputEl.type === 'radio') && inputEl.id) {
440
+ const label = formElement.querySelector(`label[for="${inputEl.id}"]`)
441
+ if (label) {
442
+ label.parentNode.insertBefore(errorEl, label.nextSibling)
443
+ } else {
444
+ inputEl.parentNode.insertBefore(errorEl, inputEl.nextSibling)
445
+ }
446
+ } else {
447
+ inputEl.parentNode.insertBefore(errorEl, inputEl.nextSibling)
448
+ }
449
+ } else if (name === '_candy_form') {
450
+ errorEl = document.createElement('div')
451
+ errorEl.setAttribute('candy-form-error', name)
452
+ errorEl.textContent = message
453
+ errorEl.style.cssText =
454
+ 'display:block;color:#dc3545;background-color:#f8d7da;border:1px solid #f5c2c7;border-radius:0.375rem;padding:0.75rem 1rem;margin-bottom:1rem;font-size:0.875rem'
455
+ formElement.insertBefore(errorEl, formElement.firstChild)
456
+ }
457
+ }
458
+ }
459
+ const inputEl = formElement.querySelector(`*[name="${name}"]`)
460
+ if (inputEl) {
461
+ if (inputEl.type !== 'checkbox' && inputEl.type !== 'radio') {
462
+ inputEl.style.borderColor = '#dc3545'
463
+ }
464
+ inputEl.addEventListener(
465
+ 'focus',
466
+ function handler() {
467
+ inputEl.style.borderColor = ''
468
+ const errorEl = formElement.querySelector(`[candy-form-error="${name}"]`)
469
+ if (errorEl) {
470
+ errorEl.style.display = 'none'
471
+ errorEl.textContent = ''
472
+ }
473
+ inputEl.removeEventListener('focus', handler)
474
+ }.bind(this),
475
+ {once: true}
476
+ )
477
+ }
478
+ })
479
+ }
480
+ }
481
+ if (data.result.success && data.result.redirect) {
482
+ window.location.href = data.result.redirect
483
+ } else if (callback !== undefined) {
484
+ if (typeof callback === 'function') callback(data)
485
+ else if (data.result.success) window.location.replace(callback)
486
+ }
487
+ },
488
+ xhr: () => {
489
+ var xhr = new window.XMLHttpRequest()
490
+ xhr.upload.addEventListener(
491
+ 'progress',
492
+ function (evt) {
493
+ if (evt.lengthComputable) {
494
+ var percent = parseInt((100 / evt.total) * evt.loaded)
495
+ if (obj.loading) obj.loading(percent)
496
+ }
497
+ },
498
+ false
499
+ )
500
+ return xhr
501
+ },
502
+ error: () => {
503
+ console.error('CandyJS:', 'Somethings went wrong...', '\nForm: ' + obj.form + '\nRequest: ' + formElement.getAttribute('action'))
504
+ },
505
+ complete: () => {
506
+ const submitButtons = formElement.querySelectorAll('button[type="submit"], input[type="submit"]')
507
+ submitButtons.forEach(btn => {
508
+ btn.disabled = false
509
+ const originalText = btn.getAttribute('data-original-text')
510
+ if (originalText) {
511
+ btn.textContent = originalText
512
+ btn.removeAttribute('data-original-text')
513
+ }
514
+ })
515
+ formElement.querySelectorAll('input:not([type="hidden"]), textarea, select').forEach(el => (el.disabled = false))
516
+ }
517
+ })
518
+ }
519
+
520
+ document.addEventListener('submit', handler)
521
+ this.#formSubmitHandlers.set(formSelector, handler)
522
+ }
523
+
524
+ get(url, callback) {
525
+ url = url + '?_token=' + this.token()
526
+ this.#ajax({url: url, success: callback})
527
+ }
528
+
529
+ page() {
530
+ if (!this.#page) {
531
+ this.#page = document.documentElement.dataset.candyPage || ''
532
+ }
533
+ return this.#page
534
+ }
535
+
536
+ storage(key, value) {
537
+ if (value === undefined) return localStorage.getItem(key)
538
+ else if (value === null) return localStorage.removeItem(key)
539
+ else localStorage.setItem(key, value)
540
+ }
541
+
542
+ token() {
543
+ if (!this.#token.listener) {
544
+ document.addEventListener('candy:ajaxSuccess', event => {
545
+ const {detail} = event
546
+ const {xhr, requestUrl} = detail
547
+ if (requestUrl.includes('://')) return false
548
+ try {
549
+ const token = xhr.getResponseHeader('X-Candy-Token')
550
+ if (token) this.#token.hash.push(token)
551
+ if (this.#token.hash.length > 2) this.#token.hash.shift()
552
+ } catch (e) {
553
+ console.error('Error in ajaxSuccess token handler:', e)
554
+ }
555
+ })
556
+ this.#token.listener = true
557
+ }
558
+ if (!this.#token.hash.length) {
559
+ var req = new XMLHttpRequest()
560
+ req.open('GET', '/', false)
561
+ req.setRequestHeader('X-Candy', 'token')
562
+ req.setRequestHeader('X-Candy-Client', this.client())
563
+ req.send(null)
564
+ var req_data = JSON.parse(req.response)
565
+ if (req_data.token) this.#token.hash.push(req_data.token)
566
+ }
567
+ this.#token.hash.filter(n => n)
568
+ var return_token = this.#token.hash.shift()
569
+ if (!this.#token.hash.length)
570
+ this.#ajax({
571
+ url: '/',
572
+ type: 'GET',
573
+ headers: {'X-Candy': 'token', 'X-Candy-Client': this.client()},
574
+ success: data => {
575
+ var result = JSON.parse(JSON.stringify(data))
576
+ if (result.token) this.#token.hash.push(result.token)
577
+ }
578
+ })
579
+ return return_token
580
+ }
581
+
582
+ load(url, callback, push = true) {
583
+ if (this.#isNavigating) return false
584
+
585
+ const currentUrl = window.location.href
586
+
587
+ // Normalize URL to be absolute
588
+ url = new URL(url, currentUrl).href
589
+
590
+ if (url === '' || url.startsWith('javascript:') || url.includes('#')) return false
591
+
592
+ this.#isNavigating = true
593
+
594
+ const currentSkeleton = document.documentElement.dataset.candySkeleton
595
+ const elements = Object.entries(this.#loader.elements)
596
+
597
+ // Collect elements to update
598
+ const elementsToUpdate = []
599
+ elements.forEach(([key, selector]) => {
600
+ const element = document.querySelector(selector)
601
+ if (element) {
602
+ elementsToUpdate.push({key, element})
603
+ }
604
+ })
605
+
606
+ let ajaxData = null
607
+ let ajaxXhr = null
608
+ let fadeOutComplete = false
609
+ let ajaxComplete = false
610
+
611
+ const applyUpdate = () => {
612
+ if (!fadeOutComplete || !ajaxComplete || !ajaxData) return
613
+
614
+ const finalUrl = ajaxXhr.responseURL || url
615
+
616
+ if (ajaxData.skeletonChanged) {
617
+ window.location.href = finalUrl
618
+ return
619
+ }
620
+
621
+ if (finalUrl !== currentUrl && push) {
622
+ window.history.pushState(null, document.title, finalUrl)
623
+ }
624
+
625
+ const newPage = ajaxXhr.getResponseHeader('X-Candy-Page')
626
+ if (newPage !== null) {
627
+ this.#page = newPage
628
+ document.documentElement.dataset.candyPage = newPage
629
+ }
630
+
631
+ if (elementsToUpdate.length === 0) {
632
+ this.#handleLoadComplete(ajaxData, callback)
633
+ return
634
+ }
635
+
636
+ // Update content and fade in
637
+ let completed = 0
638
+ elementsToUpdate.forEach(({key, element}) => {
639
+ if (ajaxData.output && ajaxData.output[key] !== undefined) {
640
+ element.innerHTML = ajaxData.output[key]
641
+ }
642
+ this.#fadeIn(element, 200, () => {
643
+ completed++
644
+ if (completed === elementsToUpdate.length) {
645
+ this.#handleLoadComplete(ajaxData, callback)
646
+ }
647
+ })
648
+ })
649
+ }
650
+
651
+ // Start fade out
652
+ if (elementsToUpdate.length > 0) {
653
+ let fadeOutCount = 0
654
+ elementsToUpdate.forEach(({element}) => {
655
+ this.#fadeOut(element, 200, () => {
656
+ fadeOutCount++
657
+ if (fadeOutCount === elementsToUpdate.length) {
658
+ fadeOutComplete = true
659
+ applyUpdate()
660
+ }
661
+ })
662
+ })
663
+ } else {
664
+ fadeOutComplete = true
665
+ }
666
+
667
+ this.#ajax({
668
+ url: url,
669
+ type: 'GET',
670
+ headers: {
671
+ 'X-Candy': 'ajaxload',
672
+ 'X-Candy-Load': Object.keys(this.#loader.elements).join(','),
673
+ 'X-Candy-Skeleton': currentSkeleton || ''
674
+ },
675
+ dataType: 'json',
676
+ success: (data, status, xhr) => {
677
+ ajaxData = data
678
+ ajaxXhr = xhr
679
+ ajaxComplete = true
680
+ applyUpdate()
681
+ },
682
+ error: () => {
683
+ this.#isNavigating = false
684
+ window.location.replace(url)
685
+ }
686
+ })
687
+ }
688
+
689
+ #handleLoadComplete(data, callback) {
690
+ // Call load actions
691
+ if (this.actions.load) {
692
+ if (Array.isArray(this.actions.load)) {
693
+ this.actions.load.forEach(fn => fn(this.page(), data.variables))
694
+ } else if (typeof this.actions.load === 'function') {
695
+ this.actions.load(this.page(), data.variables)
696
+ }
697
+ }
698
+
699
+ // Call page-specific actions
700
+ if (this.actions.page && this.actions.page[this.page()]) {
701
+ const pageActions = this.actions.page[this.page()]
702
+ if (Array.isArray(pageActions)) {
703
+ pageActions.forEach(fn => fn(data.variables))
704
+ } else if (typeof pageActions === 'function') {
705
+ pageActions(data.variables)
706
+ }
707
+ }
708
+
709
+ // Call custom callback
710
+ if (callback && typeof callback === 'function') {
711
+ callback(this.page(), data.variables)
712
+ }
713
+
714
+ // Scroll to top
715
+ window.scrollTo({top: 0, behavior: 'smooth'})
716
+
717
+ this.#isNavigating = false
718
+ }
719
+
720
+ loader(selector, elements, callback) {
721
+ this.#loader.elements = elements
722
+ this.#loader.callback = callback
723
+
724
+ const candyInstance = this
725
+
726
+ // Handle link clicks
727
+ this.#on(document, 'click', selector, function (e) {
728
+ if (e.ctrlKey || e.metaKey) return
729
+
730
+ const anchor = this
731
+ if (!anchor) return
732
+
733
+ const url = anchor.getAttribute('href')
734
+ const target = anchor.getAttribute('target')
735
+
736
+ if (!url || url === '' || url.startsWith('javascript:') || url.startsWith('#')) return
737
+
738
+ const currentHost = window.location.host
739
+ const isExternal = url.includes('://') && !url.includes(currentHost)
740
+
741
+ if ((target === null || target === '_self') && !isExternal) {
742
+ e.preventDefault()
743
+ candyInstance.load(url, callback)
744
+ }
745
+ })
746
+
747
+ // Handle browser back/forward
748
+ window.addEventListener('popstate', () => {
749
+ this.load(window.location.href, callback, false)
750
+ })
751
+ }
752
+
753
+ listen(url, onMessage, options = {}) {
754
+ const {onError = null, onOpen = null, autoReconnect = false, reconnectDelay = 3000} = options
755
+
756
+ let eventSource = null
757
+ let reconnectTimer = null
758
+ let isClosed = false
759
+
760
+ const connect = () => {
761
+ if (isClosed) return
762
+
763
+ const urlWithToken = url + (url.includes('?') ? '&' : '?') + '_token=' + encodeURIComponent(this.token())
764
+ eventSource = new EventSource(urlWithToken)
765
+
766
+ eventSource.onopen = e => {
767
+ if (onOpen) onOpen(e)
768
+ }
769
+
770
+ eventSource.onmessage = e => {
771
+ try {
772
+ const data = JSON.parse(e.data)
773
+ onMessage(data)
774
+ } catch {
775
+ onMessage(e.data)
776
+ }
777
+ }
778
+
779
+ eventSource.onerror = e => {
780
+ if (onError) onError(e)
781
+
782
+ if (autoReconnect && !isClosed) {
783
+ eventSource.close()
784
+ reconnectTimer = setTimeout(connect, reconnectDelay)
785
+ }
786
+ }
787
+ }
788
+
789
+ connect()
790
+
791
+ return {
792
+ close: () => {
793
+ isClosed = true
794
+ if (reconnectTimer) clearTimeout(reconnectTimer)
795
+ if (eventSource) eventSource.close()
796
+ },
797
+ send: () => {
798
+ throw new Error('SSE is one-way. Use POST requests to send data.')
799
+ }
800
+ }
801
+ }
802
+ }
803
+
804
+ window.Candy = new candy()
805
+
806
+ // Auto-initialize navigation from data-candy-navigate attribute
807
+ ;(function initAutoNavigate() {
808
+ const init = () => {
809
+ const contentEl = document.querySelector('[data-candy-navigate="content"]')
810
+ if (contentEl) {
811
+ window.Candy.loader('a[href^="/"]:not([data-navigate="false"]):not(.no-navigate)', {content: '[data-candy-navigate="content"]'}, null)
812
+ }
813
+ }
814
+
815
+ if (document.readyState === 'loading') {
816
+ document.addEventListener('DOMContentLoaded', init)
817
+ } else {
818
+ init()
819
+ }
820
+ })()
821
+
822
+ document.addEventListener('DOMContentLoaded', () => {
823
+ const formTypes = ['register', 'login']
824
+
825
+ formTypes.forEach(type => {
826
+ const forms = document.querySelectorAll(`form.candy-${type}-form[data-candy-${type}]`)
827
+ forms.forEach(form => {
828
+ const token = form.getAttribute(`data-candy-${type}`)
829
+ window.Candy.form({form: `form[data-candy-${type}="${token}"]`})
830
+ })
831
+ })
832
+
833
+ const customForms = document.querySelectorAll('form.candy-custom-form[data-candy-form]')
834
+ customForms.forEach(form => {
835
+ const token = form.getAttribute('data-candy-form')
836
+ window.Candy.form({form: `form[data-candy-form="${token}"]`})
837
+ })
838
+ })