odac 1.4.3 → 1.4.5

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 (63) hide show
  1. package/.agent/rules/coding.md +2 -2
  2. package/.agent/rules/memory.md +5 -1
  3. package/.github/workflows/release.yml +2 -0
  4. package/.husky/pre-push +0 -1
  5. package/.kiro/steering/coding.md +27 -0
  6. package/.kiro/steering/memory.md +56 -0
  7. package/.kiro/steering/project.md +30 -0
  8. package/.kiro/steering/workflow.md +16 -0
  9. package/CHANGELOG.md +98 -0
  10. package/README.md +2 -1
  11. package/client/odac.js +121 -2
  12. package/docs/ai/skills/backend/authentication.md +7 -5
  13. package/docs/ai/skills/backend/controllers.md +24 -3
  14. package/docs/ai/skills/backend/forms.md +8 -6
  15. package/docs/ai/skills/backend/image-processing.md +93 -0
  16. package/docs/ai/skills/backend/request_response.md +2 -2
  17. package/docs/ai/skills/backend/routing.md +11 -0
  18. package/docs/ai/skills/backend/structure.md +1 -1
  19. package/docs/ai/skills/backend/views.md +34 -9
  20. package/docs/ai/skills/frontend/navigation.md +45 -1
  21. package/docs/ai/skills/frontend/realtime.md +18 -2
  22. package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +24 -0
  23. package/docs/backend/07-views/03-template-syntax.md +65 -15
  24. package/docs/backend/07-views/03-variables.md +22 -7
  25. package/docs/backend/07-views/11-image-optimization.md +197 -0
  26. package/docs/frontend/02-ajax-navigation/01-quick-start.md +22 -0
  27. package/docs/frontend/02-ajax-navigation/03-advanced-usage.md +51 -0
  28. package/package.json +5 -2
  29. package/src/Auth.js +8 -4
  30. package/src/Config.js +5 -0
  31. package/src/Database/ConnectionFactory.js +16 -0
  32. package/src/Ipc.js +3 -2
  33. package/src/Lang.js +17 -10
  34. package/src/Odac.js +1 -0
  35. package/src/Request.js +20 -20
  36. package/src/Route.js +39 -3
  37. package/src/Validator.js +5 -5
  38. package/src/View/Image.js +495 -0
  39. package/src/View.js +4 -0
  40. package/template/controller/page/about.js +3 -3
  41. package/template/controller/page/index.js +2 -2
  42. package/template/public/assets/js/app.js +38 -54
  43. package/template/skeleton/main.html +4 -4
  44. package/template/view/content/about.html +64 -60
  45. package/template/view/content/home.html +148 -175
  46. package/template/view/css/app.css +46 -0
  47. package/template/view/footer/main.html +10 -9
  48. package/template/view/header/main.html +34 -11
  49. package/test/Auth/verifyMagicLink.test.js +281 -0
  50. package/test/Client/load.test.js +306 -0
  51. package/test/Lang/get.test.js +37 -11
  52. package/test/Odac/image.test.js +61 -0
  53. package/test/Route/set.test.js +102 -0
  54. package/test/View/Image/buildFilename.test.js +62 -0
  55. package/test/View/Image/hash.test.js +59 -0
  56. package/test/View/Image/isAvailable.test.js +15 -0
  57. package/test/View/Image/parse.test.js +83 -0
  58. package/test/View/Image/process.test.js +38 -0
  59. package/test/View/Image/render.test.js +117 -0
  60. package/test/View/Image/serve.test.js +56 -0
  61. package/test/View/Image/url.test.js +53 -0
  62. package/test/View/constructor.test.js +10 -0
  63. package/template/public/assets/css/style.css +0 -1835
@@ -143,6 +143,57 @@ Odac.action({
143
143
 
144
144
  ## Animation & Transitions
145
145
 
146
+ ### View Transitions (Recommended)
147
+
148
+ ODAC natively supports the browser's [View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API). Add the `odac-transition` attribute to any element that should animate between page navigations. No JavaScript configuration is needed.
149
+
150
+ ```html
151
+ <header odac-transition="header">Site Header</header>
152
+ <nav odac-transition="sidebar">Navigation</nav>
153
+ <main>Regular content (updated by AJAX loader)</main>
154
+ <img odac-transition="hero" src="/hero.jpg" alt="Hero" />
155
+ ```
156
+
157
+ **How it works:**
158
+ - Before navigation, ODAC assigns `view-transition-name` to each `odac-transition` element
159
+ - The browser captures a snapshot of the old state
160
+ - DOM is updated with new content
161
+ - New `odac-transition` elements receive their transition names
162
+ - The browser animates between old and new snapshots
163
+
164
+ **Rules:**
165
+ 1. Each `odac-transition` value must be unique within the page (browser requirement)
166
+ 2. Elements that persist across pages (e.g., shared header) will morph smoothly
167
+ 3. If the browser doesn't support View Transition API, the legacy fade animation runs automatically
168
+
169
+ **CSS Customization:**
170
+
171
+ ```css
172
+ /* Crossfade the hero image */
173
+ ::view-transition-old(hero) {
174
+ animation: fade-out 0.3s ease;
175
+ }
176
+ ::view-transition-new(hero) {
177
+ animation: fade-in 0.3s ease;
178
+ }
179
+
180
+ /* Slide the sidebar */
181
+ ::view-transition-old(sidebar) {
182
+ animation: slide-out-left 0.25s ease;
183
+ }
184
+ ::view-transition-new(sidebar) {
185
+ animation: slide-in-left 0.25s ease;
186
+ }
187
+
188
+ /* Default transition for all elements */
189
+ ::view-transition-old(*) {
190
+ animation-duration: 0.2s;
191
+ }
192
+ ::view-transition-new(*) {
193
+ animation-duration: 0.2s;
194
+ }
195
+ ```
196
+
146
197
  ### Custom Transitions
147
198
 
148
199
  Add custom animations:
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "email": "mail@emre.red",
8
8
  "url": "https://emre.red"
9
9
  },
10
- "version": "1.4.3",
10
+ "version": "1.4.5",
11
11
  "license": "MIT",
12
12
  "engines": {
13
13
  "node": ">=18.0.0"
@@ -24,10 +24,13 @@
24
24
  "@tailwindcss/cli": "^4.1.18",
25
25
  "knex": "^3.1.0",
26
26
  "lmdb": "^3.4.4",
27
+ "tailwindcss": "^4.1.18"
28
+ },
29
+ "optionalDependencies": {
27
30
  "mysql2": "^3.16.0",
28
31
  "pg": "^8.16.3",
29
32
  "redis": "^5.10.0",
30
- "tailwindcss": "^4.1.18"
33
+ "sharp": "^0.33.0"
31
34
  },
32
35
  "overrides": {
33
36
  "tar": "7.5.9",
package/src/Auth.js CHANGED
@@ -593,6 +593,7 @@ class Auth {
593
593
 
594
594
  // 4. Log in user (or Register if new)
595
595
  let user = await Odac.DB[this.#table].where('email', email).first()
596
+ let alreadyLoggedIn = false
596
597
 
597
598
  if (!user) {
598
599
  // Auto-Register the user
@@ -630,12 +631,15 @@ class Auth {
630
631
  }
631
632
 
632
633
  user = regResult.user
634
+ // register() already performs auto-login by default, skip duplicate login
635
+ alreadyLoggedIn = regResult.autoLogin !== false
633
636
  }
634
637
 
635
- // Login logic similar to login()
636
- const loginData = {}
637
- loginData[primaryKey] = user[primaryKey]
638
- await this.login(loginData)
638
+ if (!alreadyLoggedIn) {
639
+ const loginData = {}
640
+ loginData[primaryKey] = user[primaryKey]
641
+ await this.login(loginData)
642
+ }
639
643
 
640
644
  return {success: true, user: user}
641
645
  }
package/src/Config.js CHANGED
@@ -18,6 +18,11 @@ module.exports = {
18
18
  auto: true,
19
19
  maxResources: 5
20
20
  },
21
+ image: {
22
+ quality: 80,
23
+ maxDimension: 4096,
24
+ format: 'webp'
25
+ },
21
26
  ipc: {
22
27
  driver: 'memory',
23
28
  redis: 'default'
@@ -35,6 +35,13 @@ function buildConnectionConfig(db, client) {
35
35
  }
36
36
  }
37
37
 
38
+ /** @type {Record<string, string>} Maps ODAC db type to the npm package that must be installed */
39
+ const DRIVER_PACKAGES = {
40
+ pg: 'pg',
41
+ mysql2: 'mysql2',
42
+ sqlite3: 'sqlite3'
43
+ }
44
+
38
45
  /**
39
46
  * Creates knex connections map from ODAC database config.
40
47
  * Why: Centralizes zero-config connection bootstrap used by runtime and migration CLI.
@@ -51,6 +58,15 @@ function buildConnections(databaseConfig) {
51
58
  const client = resolveClient(db.type)
52
59
  const connection = buildConnectionConfig(db, client)
53
60
 
61
+ const pkg = DRIVER_PACKAGES[client]
62
+ if (pkg) {
63
+ try {
64
+ require(pkg)
65
+ } catch {
66
+ throw new Error(`Database driver "${pkg}" is not installed. Run: npm install ${pkg}`)
67
+ }
68
+ }
69
+
54
70
  connections[key] = knex({
55
71
  client,
56
72
  connection,
package/src/Ipc.js CHANGED
@@ -122,8 +122,9 @@ class Ipc extends EventEmitter {
122
122
  this.redis = Redis.createClient(Odac.Config.database?.redis?.[this.config.redis || 'default'] || {})
123
123
  await this.redis.connect()
124
124
  } catch (e) {
125
- console.error('IPC Redis Driver Error:', e)
126
- // Re-throw to ensure application doesn't start in a broken state.
125
+ if (e.code === 'MODULE_NOT_FOUND') {
126
+ throw new Error('IPC Redis driver requires the "redis" package. Run: npm install redis')
127
+ }
127
128
  throw e
128
129
  }
129
130
  }
package/src/Lang.js CHANGED
@@ -1,4 +1,5 @@
1
- const fs = require('fs')
1
+ const fs = require('node:fs')
2
+ const fsPromises = fs.promises
2
3
 
3
4
  class Lang {
4
5
  #odac
@@ -10,7 +11,7 @@ class Lang {
10
11
  this.set()
11
12
  }
12
13
 
13
- get(...args) {
14
+ async get(...args) {
14
15
  if (typeof args[0] !== 'string') return args[0]
15
16
  if (!this.#data[args[0]]) {
16
17
  this.#data[args[0]] = args[0]
@@ -34,11 +35,11 @@ class Lang {
34
35
  return str
35
36
  }
36
37
 
37
- #save() {
38
+ async #save() {
38
39
  if (!this.#lang) return
39
- if (!fs.existsSync(__dir + '/storage/')) fs.mkdirSync(__dir + '/storage/')
40
- if (!fs.existsSync(__dir + '/storage/language/')) fs.mkdirSync(__dir + '/storage/language/')
41
- fs.writeFileSync(__dir + '/storage/language/' + this.#lang + '.json', JSON.stringify(this.#data, null, 4))
40
+ const langDir = __dir + '/storage/language/'
41
+ if (!fs.existsSync(langDir)) await fsPromises.mkdir(langDir, {recursive: true})
42
+ await fsPromises.writeFile(langDir + this.#lang + '.json', JSON.stringify(this.#data, null, 4))
42
43
  }
43
44
 
44
45
  set(lang) {
@@ -49,15 +50,21 @@ class Lang {
49
50
  this.#odac.Request.header('ACCEPT-LANGUAGE') &&
50
51
  this.#odac.Request.header('ACCEPT-LANGUAGE').length > 1
51
52
  ) {
52
- lang = this.#odac.Request.header('ACCEPT-LANGUAGE').substr(0, 2)
53
+ lang = this.#odac.Request.header('ACCEPT-LANGUAGE').slice(0, 2)
53
54
  } else {
54
55
  lang = this.#odac.Config.lang?.default || 'en'
55
56
  }
56
57
  }
57
58
  this.#lang = lang
58
- if (fs.existsSync(__dir + '/storage/language/' + lang + '.json'))
59
- this.#data = JSON.parse(fs.readFileSync(__dir + '/storage/language/' + lang + '.json'))
60
- else this.#data = {}
59
+ const langFile = __dir + '/storage/language/' + lang + '.json'
60
+ if (fs.existsSync(langFile)) {
61
+ // Use Sync read here only because it's during initialization/request entry
62
+ // and we need to block briefly to ensure data is ready before logic proceeds.
63
+ // In a high-load system, this should Ideally be pre-cached.
64
+ this.#data = JSON.parse(fs.readFileSync(langFile, 'utf8'))
65
+ } else {
66
+ this.#data = {}
67
+ }
61
68
  }
62
69
  }
63
70
 
package/src/Odac.js CHANGED
@@ -62,6 +62,7 @@ module.exports = {
62
62
  _odac.Server = require('./Server.js')
63
63
  _odac.Storage = require('./Storage.js')
64
64
  _odac.Var = (...args) => new (require('./Var.js'))(...args)
65
+ _odac.image = (src, options) => require('./View/Image.js').url(src, options)
65
66
 
66
67
  if (req) {
67
68
  _odac.setInterval = function (callback, delay, ...args) {
package/src/Request.js CHANGED
@@ -32,17 +32,17 @@ class OdacRequest {
32
32
  delete this.req.headers['x-odac-connection-ssl']
33
33
  delete this.req.headers['x-odac-connection-remoteaddress']
34
34
  let route = req.headers.host.split('.')[0]
35
- if (!Odac.Route.routes[route]) route = 'www'
35
+ if (!global.Odac.Route.routes[route]) route = 'www'
36
36
  this.route = route
37
37
  if (this.res) {
38
38
  if (typeof this.#odac.setTimeout === 'function') {
39
- this.#timeout = this.#odac.setTimeout(() => !this.res.finished && this.abort(408), Odac.Config.request.timeout)
39
+ this.#timeout = this.#odac.setTimeout(() => !this.res.finished && this.abort(408), global.Odac.Config.request.timeout)
40
40
  } else {
41
- this.#timeout = setTimeout(() => !this.res.finished && this.abort(408), Odac.Config.request.timeout)
41
+ this.#timeout = setTimeout(() => !this.res.finished && this.abort(408), global.Odac.Config.request.timeout)
42
42
  }
43
43
  }
44
44
  this.#data()
45
- if (!Odac.Request) Odac.Request = {}
45
+ if (!global.Odac.Request) global.Odac.Request = {}
46
46
  }
47
47
 
48
48
  // - ABORT REQUEST
@@ -50,11 +50,11 @@ class OdacRequest {
50
50
  this.status(code)
51
51
  let result = {401: 'Unauthorized', 404: 'Not Found', 408: 'Request Timeout'}[code] ?? null
52
52
  if (
53
- Odac.Route.routes[this.route].error &&
54
- Odac.Route.routes[this.route].error[code] &&
55
- typeof Odac.Route.routes[this.route].error[code].cache === 'function'
53
+ global.Odac.Route.routes[this.route].error &&
54
+ global.Odac.Route.routes[this.route].error[code] &&
55
+ typeof global.Odac.Route.routes[this.route].error[code].cache === 'function'
56
56
  )
57
- result = await Odac.Route.routes[this.route].error[code].cache(this.#odac)
57
+ result = await global.Odac.Route.routes[this.route].error[code].cache(this.#odac)
58
58
  this.end(result)
59
59
  }
60
60
 
@@ -250,30 +250,30 @@ class OdacRequest {
250
250
  .update(this.req.headers['user-agent'] ?? '.')
251
251
  .digest('hex')
252
252
  let pub = this.cookie('odac_session')
253
- if (!pub || !Odac.Storage.get(`sess:${pub}:${pri}:_created`)) {
253
+ if (!pub || !global.Odac.Storage.get(`sess:${pub}:${pri}:_created`)) {
254
254
  const lockKey = `lock:${this.ip}:${pri}`
255
255
  const now = Date.now()
256
256
 
257
- const existingLock = Odac.Storage.get(lockKey)
257
+ const existingLock = global.Odac.Storage.get(lockKey)
258
258
  if (existingLock) {
259
- if (now - existingLock.timestamp < 2000 && Odac.Storage.get(`sess:${existingLock.sessionId}:${pri}:_created`)) {
259
+ if (now - existingLock.timestamp < 2000 && global.Odac.Storage.get(`sess:${existingLock.sessionId}:${pri}:_created`)) {
260
260
  pub = existingLock.sessionId
261
261
  } else {
262
- Odac.Storage.remove(lockKey)
262
+ global.Odac.Storage.remove(lockKey)
263
263
  }
264
264
  }
265
265
 
266
266
  if (!pub) {
267
267
  do {
268
268
  pub = nodeCrypto.randomBytes(16).toString('hex')
269
- } while (Odac.Storage.get(`sess:${pub}:${pri}:_created`))
270
- Odac.Storage.put(lockKey, {sessionId: pub, timestamp: now})
271
- Odac.Storage.put(`sess:${pub}:${pri}:_created`, now)
269
+ } while (global.Odac.Storage.get(`sess:${pub}:${pri}:_created`))
270
+ global.Odac.Storage.put(lockKey, {sessionId: pub, timestamp: now})
271
+ global.Odac.Storage.put(`sess:${pub}:${pri}:_created`, now)
272
272
  this.cookie('odac_session', `${pub}`)
273
273
  setTimeout(() => {
274
- const lock = Odac.Storage.get(lockKey)
274
+ const lock = global.Odac.Storage.get(lockKey)
275
275
  if (lock?.timestamp === now) {
276
- Odac.Storage.remove(lockKey)
276
+ global.Odac.Storage.remove(lockKey)
277
277
  }
278
278
  }, 2000)
279
279
  }
@@ -282,15 +282,15 @@ class OdacRequest {
282
282
  const dbKey = `sess:${pub}:${pri}:${key}`
283
283
  if (value === undefined) {
284
284
  if (Object.prototype.hasOwnProperty.call(this.#sessions, dbKey)) return this.#sessions[dbKey]
285
- const dbValue = Odac.Storage.get(dbKey) ?? null
285
+ const dbValue = global.Odac.Storage.get(dbKey) ?? null
286
286
  return dbValue
287
287
  } else if (value === null) {
288
288
  delete this.#sessions[dbKey]
289
289
  delete this.#sessions[dbKey]
290
- Odac.Storage.remove(dbKey)
290
+ global.Odac.Storage.remove(dbKey)
291
291
  } else {
292
292
  this.#sessions[dbKey] = value
293
- Odac.Storage.put(dbKey, value)
293
+ global.Odac.Storage.put(dbKey, value)
294
294
  }
295
295
  }
296
296
 
package/src/Route.js CHANGED
@@ -4,10 +4,11 @@ const path = require('path')
4
4
 
5
5
  const Cron = require('./Route/Cron.js')
6
6
  const Internal = require('./Route/Internal.js')
7
+ const Image = require('./View/Image.js')
7
8
  const MiddlewareChain = require('./Route/Middleware.js')
8
9
  const {WebSocketServer} = require('./WebSocket.js')
9
10
 
10
- var routes2 = {}
11
+ let routes2 = {}
11
12
  const mime = require('./Route/MimeTypes.js')
12
13
 
13
14
  class Route {
@@ -586,6 +587,29 @@ class Route {
586
587
  {token: false}
587
588
  )
588
589
 
590
+ this.set(
591
+ 'GET',
592
+ '/_odac/img/{file}',
593
+ async Odac => {
594
+ const filename = Odac.Request.data.url.file
595
+ if (!filename) return Odac.Request.abort(404)
596
+
597
+ // Validate filename format: {name}-{dimension}-{hash8}.{ext}
598
+ if (!/^[\w-]+-(?:\d+|o)-[a-f0-9]{8}\.(webp|avif|png|jpeg|jpg|tiff)$/.test(filename)) {
599
+ return Odac.Request.abort(404)
600
+ }
601
+
602
+ const result = await Image.serve(filename)
603
+ if (!result) return Odac.Request.abort(404)
604
+
605
+ Odac.Request.header('Content-Type', result.type)
606
+ Odac.Request.header('Content-Length', result.size)
607
+ Odac.Request.header('Cache-Control', 'public, max-age=31536000, immutable')
608
+ return result.stream
609
+ },
610
+ {token: false}
611
+ )
612
+
589
613
  delete Odac.Route.buff
590
614
  }
591
615
 
@@ -679,6 +703,13 @@ class Route {
679
703
  // File error, proceed to reload or re-set
680
704
  }
681
705
  } else {
706
+ // Update inline function reference on hot reload
707
+ this.routes[Odac.Route.buff][type][url].cache = file
708
+ this.routes[Odac.Route.buff][type][url].file = file
709
+ this.routes[Odac.Route.buff][type][url].mtime = Date.now()
710
+ this.routes[Odac.Route.buff][type][url].middlewares = capturedMiddlewares
711
+ this.routes[Odac.Route.buff][type][url].token = options.token ?? true
712
+ this.routes[Odac.Route.buff][type][url].action = action
682
713
  return
683
714
  }
684
715
  }
@@ -709,9 +740,14 @@ class Route {
709
740
  this.routes[Odac.Route.buff][type][url].action = action
710
741
 
711
742
  this.routes[Odac.Route.buff][type][url].middlewares = capturedMiddlewares
712
- } catch {
743
+ } catch (err) {
713
744
  if (file && typeof file === 'string') {
714
- console.error(`\x1b[31m[Odac]\x1b[0m Controller not found: \x1b[33m${path}\x1b[0m`)
745
+ if (err.code === 'ENOENT') {
746
+ console.error(`\x1b[31m[Odac]\x1b[0m Controller not found: \x1b[33m${path}\x1b[0m`)
747
+ } else {
748
+ console.error(`\x1b[31m[Odac]\x1b[0m Failed to load controller: \x1b[33m${path}\x1b[0m`)
749
+ console.error(`\x1b[31m[Odac]\x1b[0m ${err.message}`)
750
+ }
715
751
  }
716
752
  }
717
753
  }
package/src/Validator.js CHANGED
@@ -343,10 +343,10 @@ class Validator {
343
343
  }
344
344
 
345
345
  async brute(maxAttempts = 5) {
346
- const ip = this.#request.ip()
346
+ const ip = this.#request.ip
347
347
  const now = new Date().toISOString().slice(0, 13).replace(/[-:T]/g, '')
348
- const page = this.#request.path()
349
- const storage = this.#odac.storage('sys')
348
+ const page = this.#request.url.split('?')[0]
349
+ const storage = this.#odac.Storage
350
350
  const validation = storage.get('validation') || {}
351
351
 
352
352
  this.#name = '_odac_form'
@@ -361,12 +361,12 @@ class Validator {
361
361
 
362
362
  if (validation.brute[now][page][ip] >= maxAttempts) {
363
363
  this.#message['_odac_form'] = this.#odac.Lang
364
- ? this.#odac.Lang.get('Too many failed attempts. Please try again later.')
364
+ ? await this.#odac.Lang.get('Too many failed attempts. Please try again later.')
365
365
  : 'Too many failed attempts. Please try again later.'
366
366
  }
367
367
  }
368
368
 
369
- storage.set('validation', validation)
369
+ storage.put('validation', validation)
370
370
  return this
371
371
  }
372
372
  }