odac 1.4.3 → 1.4.4

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 (44) hide show
  1. package/.agent/rules/coding.md +2 -2
  2. package/.github/workflows/release.yml +2 -0
  3. package/.husky/pre-push +0 -1
  4. package/.kiro/steering/coding.md +27 -0
  5. package/.kiro/steering/memory.md +56 -0
  6. package/.kiro/steering/project.md +30 -0
  7. package/.kiro/steering/workflow.md +16 -0
  8. package/CHANGELOG.md +70 -0
  9. package/docs/ai/skills/backend/authentication.md +7 -5
  10. package/docs/ai/skills/backend/controllers.md +24 -3
  11. package/docs/ai/skills/backend/forms.md +8 -6
  12. package/docs/ai/skills/backend/image-processing.md +93 -0
  13. package/docs/ai/skills/backend/request_response.md +2 -2
  14. package/docs/ai/skills/backend/routing.md +11 -0
  15. package/docs/ai/skills/backend/structure.md +1 -1
  16. package/docs/ai/skills/frontend/realtime.md +18 -2
  17. package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +24 -0
  18. package/docs/backend/07-views/03-template-syntax.md +18 -2
  19. package/docs/backend/07-views/11-image-optimization.md +197 -0
  20. package/package.json +5 -2
  21. package/src/Auth.js +8 -4
  22. package/src/Config.js +5 -0
  23. package/src/Database/ConnectionFactory.js +16 -0
  24. package/src/Ipc.js +3 -2
  25. package/src/Lang.js +17 -10
  26. package/src/Odac.js +1 -0
  27. package/src/Request.js +20 -20
  28. package/src/Route.js +39 -3
  29. package/src/Validator.js +5 -5
  30. package/src/View/Image.js +495 -0
  31. package/src/View.js +4 -0
  32. package/test/Auth/verifyMagicLink.test.js +281 -0
  33. package/test/Lang/get.test.js +37 -11
  34. package/test/Odac/image.test.js +61 -0
  35. package/test/Route/set.test.js +102 -0
  36. package/test/View/Image/buildFilename.test.js +62 -0
  37. package/test/View/Image/hash.test.js +59 -0
  38. package/test/View/Image/isAvailable.test.js +15 -0
  39. package/test/View/Image/parse.test.js +83 -0
  40. package/test/View/Image/process.test.js +38 -0
  41. package/test/View/Image/render.test.js +117 -0
  42. package/test/View/Image/serve.test.js +56 -0
  43. package/test/View/Image/url.test.js +53 -0
  44. package/test/View/constructor.test.js +10 -0
@@ -0,0 +1,495 @@
1
+ const nodeCrypto = require('crypto')
2
+ const fs = require('fs')
3
+ const fsPromises = fs.promises
4
+ const path = require('path')
5
+
6
+ const IMG_CACHE_DIR = './storage/.cache/img'
7
+
8
+ /**
9
+ * Handles on-demand image processing (resize + format conversion) for the
10
+ * ODAC template engine's `<odac:img>` tag. Uses sharp as an optional dependency
11
+ * to keep the framework lightweight — when sharp is unavailable, the tag
12
+ * gracefully degrades to a standard `<img>` element with no processing.
13
+ *
14
+ * Processed images are cached to disk so that only the first request incurs
15
+ * the transformation cost; subsequent requests are served at near-zero latency.
16
+ */
17
+ class Image {
18
+ /** @type {Map<string, {path: string, type: string, cacheKey: string}>} In-memory index of processed images */
19
+ static #cache = new Map()
20
+
21
+ /** @type {Map<string, Promise>} In-flight processing promises to prevent duplicate work */
22
+ static #inflight = new Map()
23
+
24
+ /** @type {Map<string, number>} Source file mtime cache — eliminates per-render stat() in production */
25
+ static #mtimeCache = new Map()
26
+
27
+ /** @type {number} Maximum entries in the in-memory cache to prevent unbounded growth */
28
+ static #MAX_CACHE_SIZE = 1000
29
+
30
+ /** @type {boolean|null} Lazy-evaluated sharp availability flag */
31
+ static #sharpAvailable = null
32
+
33
+ /** @type {Set<string>} Supported output formats for format conversion */
34
+ static SUPPORTED_FORMATS = new Set(['webp', 'avif', 'png', 'jpeg', 'jpg', 'tiff'])
35
+
36
+ /** @type {Set<string>} Supported source extensions that sharp can process */
37
+ static SUPPORTED_INPUTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'avif', 'svg'])
38
+
39
+ /** @type {number} Default JPEG/WebP quality when not specified by the user */
40
+ static DEFAULT_QUALITY = 80
41
+
42
+ /** @type {number} Maximum allowed dimension to prevent abuse */
43
+ static MAX_DIMENSION = 4096
44
+
45
+ /**
46
+ * Checks whether sharp is installed and usable. The result is memoized
47
+ * so the require() probe runs at most once per process lifetime.
48
+ * @returns {boolean}
49
+ */
50
+ static isAvailable() {
51
+ if (this.#sharpAvailable !== null) return this.#sharpAvailable
52
+
53
+ try {
54
+ require('sharp')
55
+ this.#sharpAvailable = true
56
+ } catch {
57
+ this.#sharpAvailable = false
58
+ console.warn('[ODAC] <odac:img> image processing is disabled. Run: npm install sharp')
59
+ }
60
+
61
+ return this.#sharpAvailable
62
+ }
63
+
64
+ /**
65
+ * Generates a deterministic hash from the image transformation parameters.
66
+ * Identical source + options + mtime always produce the same hash, enabling
67
+ * cache deduplication across templates and requests while ensuring cache
68
+ * invalidation when the source file changes.
69
+ * @param {string} src - Source image path (relative to public/)
70
+ * @param {object} options - Transformation options (width, height, format, quality)
71
+ * @param {number} [mtime=0] - Source file modification time (ms) for cache busting
72
+ * @returns {string} 8-character hex hash
73
+ */
74
+ static hash(src, options = {}, mtime = 0) {
75
+ const payload = JSON.stringify({
76
+ src,
77
+ w: options.width || null,
78
+ h: options.height || null,
79
+ f: options.format || null,
80
+ q: options.quality || null,
81
+ m: mtime
82
+ })
83
+ return nodeCrypto.createHash('md5').update(payload).digest('hex').substring(0, 8)
84
+ }
85
+
86
+ /**
87
+ * Builds a human-readable cache filename from the source path and options.
88
+ * Pattern: {name}-{dimension}-{hash8}.{ext}
89
+ * Examples: logo-250-a1b2c3d4.webp, hero-o-f9e8d7c6.avif
90
+ *
91
+ * The dimension segment uses width if specified, otherwise 'o' (original).
92
+ * The hash suffix guarantees uniqueness across different paths, quality
93
+ * settings, height values, and source file versions (via mtime).
94
+ *
95
+ * @param {string} src - Source image path (e.g. '/images/logo.jpg')
96
+ * @param {object} options - {width, height, format, quality}
97
+ * @param {number} [mtime=0] - Source file modification time for cache busting
98
+ * @returns {string} Cache filename (e.g. 'logo-250-a1b2c3d4.webp')
99
+ */
100
+ static buildFilename(src, options = {}, mtime = 0) {
101
+ const imgHash = this.hash(src, options, mtime)
102
+ const format = this.#resolveFormat(src, options.format)
103
+ const basename = path.basename(src, path.extname(src)).replace(/[^a-zA-Z0-9_-]/g, '_')
104
+ const dimension = options.width ? String(parseInt(options.width, 10)) : 'o'
105
+ return `${basename}-${dimension}-${imgHash}.${format}`
106
+ }
107
+
108
+ /**
109
+ * Resolves the output format: uses the requested format if valid,
110
+ * otherwise falls back to the source file's extension.
111
+ * @param {string} src - Source file path
112
+ * @param {string|null} requestedFormat - User-requested output format
113
+ * @returns {string} Normalized format string (e.g. 'webp', 'jpeg')
114
+ */
115
+ static #resolveFormat(src, requestedFormat) {
116
+ if (requestedFormat) {
117
+ const normalized = requestedFormat.toLowerCase()
118
+ if (normalized === 'jpg') return 'jpeg'
119
+ if (this.SUPPORTED_FORMATS.has(normalized)) return normalized
120
+ }
121
+
122
+ const ext = path.extname(src).slice(1).toLowerCase()
123
+ if (ext === 'jpg') return 'jpeg'
124
+ if (this.SUPPORTED_FORMATS.has(ext)) return ext
125
+
126
+ return 'jpeg'
127
+ }
128
+
129
+ /**
130
+ * Clamps a dimension value to safe bounds, preventing resource exhaustion
131
+ * from absurdly large resize requests.
132
+ * @param {string|number|null} value - Raw dimension value from template attribute
133
+ * @returns {number|null} Clamped integer or null if not specified
134
+ */
135
+ static #parseDimension(value) {
136
+ if (!value) return null
137
+ const num = parseInt(value, 10)
138
+ if (isNaN(num) || num <= 0) return null
139
+ return Math.min(num, this.MAX_DIMENSION)
140
+ }
141
+
142
+ /**
143
+ * Processes a source image: resizes and/or converts format, then writes
144
+ * the result to the cache directory. Returns the cached file path.
145
+ *
146
+ * Uses sharp's pipeline API for single-pass processing (no intermediate
147
+ * buffers), keeping memory pressure minimal even for large images.
148
+ *
149
+ * Concurrent requests for the same variant are coalesced via an in-flight
150
+ * promise map, preventing duplicate sharp pipelines and file write races.
151
+ *
152
+ * @param {string} src - Source path relative to public/ (e.g. '/images/hero.jpg')
153
+ * @param {object} options - {width, height, format, quality}
154
+ * @returns {Promise<{path: string, type: string}|null>} Cached file info or null on failure
155
+ */
156
+ static async process(src, options = {}, mtime = 0) {
157
+ if (!this.isAvailable()) return null
158
+
159
+ const cacheKey = this.buildFilename(src, options, mtime)
160
+
161
+ // O(1) in-memory cache hit
162
+ if (this.#cache.has(cacheKey)) return this.#cache.get(cacheKey)
163
+
164
+ // Coalesce concurrent requests for the same variant
165
+ if (this.#inflight.has(cacheKey)) return this.#inflight.get(cacheKey)
166
+
167
+ const format = this.#resolveFormat(src, options.format)
168
+ const promise = this.#processInternal(src, cacheKey, format, options, mtime > 0)
169
+ this.#inflight.set(cacheKey, promise)
170
+
171
+ try {
172
+ return await promise
173
+ } finally {
174
+ this.#inflight.delete(cacheKey)
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Internal processing pipeline — separated from process() to keep the
180
+ * in-flight coalescing logic clean and the actual I/O isolated.
181
+ * @param {string} src - Source path relative to public/
182
+ * @param {string} cacheKey - Pre-computed cache key (hash.format)
183
+ * @param {string} format - Resolved output format
184
+ * @param {object} options - Processing options
185
+ * @param {boolean} sourceVerified - When true, skips the redundant access check (caller already stat'd the file)
186
+ * @returns {Promise<{path: string, type: string}|null>}
187
+ */
188
+ static async #processInternal(src, cacheKey, format, options, sourceVerified = false) {
189
+ const cachePath = path.join(IMG_CACHE_DIR, cacheKey)
190
+
191
+ // Disk cache hit — populate in-memory index without reprocessing
192
+ try {
193
+ await fsPromises.access(cachePath)
194
+ const result = {path: cachePath, type: `image/${format}`, cacheKey}
195
+ this.#setCacheEntry(cacheKey, result)
196
+ return result
197
+ } catch {
198
+ // Cache miss — proceed to process
199
+ }
200
+
201
+ // Resolve source file from public directory
202
+ const baseDir = global.__dir || process.cwd()
203
+ const sourcePath = path.join(baseDir, 'public', src)
204
+
205
+ // Path traversal guard
206
+ const publicDir = path.resolve(baseDir, 'public')
207
+ const resolvedSource = path.resolve(sourcePath)
208
+ if (!resolvedSource.startsWith(publicDir + path.sep) && resolvedSource !== publicDir) {
209
+ console.error(`[ODAC Image] Path traversal blocked: ${src}`)
210
+ return null
211
+ }
212
+
213
+ // Validate source extension
214
+ const sourceExt = path.extname(src).slice(1).toLowerCase()
215
+ if (!this.SUPPORTED_INPUTS.has(sourceExt)) return null
216
+
217
+ // Skip access check when render() already confirmed the file exists via stat
218
+ if (!sourceVerified) {
219
+ try {
220
+ await fsPromises.access(resolvedSource)
221
+ } catch {
222
+ return null
223
+ }
224
+ }
225
+
226
+ const width = this.#parseDimension(options.width)
227
+ const height = this.#parseDimension(options.height)
228
+ const quality = Math.min(Math.max(parseInt(options.quality, 10) || this.DEFAULT_QUALITY, 1), 100)
229
+
230
+ try {
231
+ const sharp = require('sharp')
232
+ let pipeline = sharp(resolvedSource)
233
+
234
+ // Resize only if dimensions are specified
235
+ if (width || height) {
236
+ pipeline = pipeline.resize(width, height, {
237
+ fit: 'inside',
238
+ withoutEnlargement: true
239
+ })
240
+ }
241
+
242
+ // Format conversion with quality setting
243
+ pipeline = pipeline.toFormat(format, {quality})
244
+
245
+ await fsPromises.mkdir(IMG_CACHE_DIR, {recursive: true})
246
+ await pipeline.toFile(cachePath)
247
+
248
+ const result = {path: cachePath, type: `image/${format}`, cacheKey}
249
+ this.#setCacheEntry(cacheKey, result)
250
+ return result
251
+ } catch (e) {
252
+ console.error(`[ODAC Image] Processing failed for "${src}":`, e.message)
253
+ return null
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Adds an entry to the in-memory cache with FIFO eviction.
259
+ * When the cache exceeds MAX_CACHE_SIZE, the oldest entry (first inserted)
260
+ * is evicted to bound memory usage in high-variant deployments.
261
+ * @param {string} key - Cache key
262
+ * @param {{path: string, type: string}} value - Cached result
263
+ */
264
+ static #setCacheEntry(key, value) {
265
+ if (this.#cache.size >= this.#MAX_CACHE_SIZE) {
266
+ const oldest = this.#cache.keys().next().value
267
+ this.#cache.delete(oldest)
268
+ }
269
+ this.#cache.set(key, value)
270
+ }
271
+
272
+ /**
273
+ * Serves a previously processed image by its cache hash. Called by the
274
+ * internal `/_odac/img/{hash}.{ext}` route handler.
275
+ *
276
+ * Returns a readable stream for zero-copy transfer to the HTTP response,
277
+ * avoiding full file buffering in memory.
278
+ *
279
+ * @param {string} filename - Cache filename (e.g. 'abc123def.webp')
280
+ * @returns {Promise<{stream: ReadableStream, type: string, size: number}|null>}
281
+ */
282
+ static async serve(filename) {
283
+ const cachePath = path.join(IMG_CACHE_DIR, filename)
284
+
285
+ // Prevent directory traversal in the filename
286
+ const resolvedCache = path.resolve(cachePath)
287
+ const cacheDir = path.resolve(IMG_CACHE_DIR)
288
+ if (!resolvedCache.startsWith(cacheDir + path.sep) && resolvedCache !== cacheDir) {
289
+ return null
290
+ }
291
+
292
+ try {
293
+ const stat = await fsPromises.stat(resolvedCache)
294
+ if (!stat.isFile()) return null
295
+
296
+ const ext = path.extname(filename).slice(1).toLowerCase()
297
+ const type = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`
298
+
299
+ return {
300
+ stream: fs.createReadStream(resolvedCache),
301
+ type,
302
+ size: stat.size
303
+ }
304
+ } catch {
305
+ return null
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Processes the image (if needed) and returns the complete `<img>` HTML tag.
311
+ * Called at template render time via the compiled `<odac:img>` tag output.
312
+ * When sharp is unavailable, gracefully degrades to a standard `<img>`.
313
+ *
314
+ * @param {object} attrs - Parsed attributes from the `<odac:img>` tag
315
+ * @returns {Promise<string>} Complete `<img>` HTML tag
316
+ */
317
+ static async render(attrs) {
318
+ const src = attrs.src || ''
319
+ const format = attrs.format || global.Odac?.Config?.image?.format || null
320
+ const quality = attrs.quality || global.Odac?.Config?.image?.quality || null
321
+
322
+ // Attributes that control processing (not passed to HTML output)
323
+ const processingAttrs = new Set(['src', 'format', 'quality'])
324
+
325
+ if (!this.isAvailable() || !src) {
326
+ return this.#renderImgTag(src, attrs, processingAttrs)
327
+ }
328
+
329
+ const options = {
330
+ width: attrs.width || null,
331
+ height: attrs.height || null,
332
+ format,
333
+ quality
334
+ }
335
+
336
+ // Production mtime cache: eliminates a stat() syscall per render when the
337
+ // source file hasn't changed. Development always stats for hot-reload.
338
+ const isDebug = global.Odac?.Config?.debug !== false
339
+ const baseDir = global.__dir || process.cwd()
340
+ const sourcePath = path.join(baseDir, 'public', src)
341
+ let mtime = 0
342
+
343
+ if (!isDebug && this.#mtimeCache.has(src)) {
344
+ mtime = this.#mtimeCache.get(src)
345
+ } else {
346
+ try {
347
+ const stat = await fsPromises.stat(sourcePath)
348
+ mtime = stat.mtimeMs
349
+ if (!isDebug) this.#mtimeCache.set(src, mtime)
350
+ } catch {
351
+ // Source not found — process() will handle the error
352
+ }
353
+ }
354
+
355
+ // Trigger processing — returns immediately on cache hit
356
+ const result = await this.process(src, options, mtime)
357
+ if (!result) {
358
+ return this.#renderImgTag(src, attrs, processingAttrs)
359
+ }
360
+
361
+ // Use cacheKey from process() result — avoids recomputing hash + buildFilename
362
+ const processedSrc = `/_odac/img/${result.cacheKey}`
363
+
364
+ return this.#renderImgTag(processedSrc, attrs, processingAttrs)
365
+ }
366
+
367
+ /**
368
+ * Returns the processed image URL without generating an HTML tag.
369
+ * Designed for use in controllers, cron jobs, mail templates, or anywhere
370
+ * a raw URL is needed (e.g. CSS background-image, JSON API responses).
371
+ *
372
+ * When sharp is unavailable or processing fails, returns the original
373
+ * source path so the caller always gets a usable URL.
374
+ *
375
+ * @param {string} src - Source path relative to public/ (e.g. '/images/hero.jpg')
376
+ * @param {object} [options] - {width, height, format, quality}
377
+ * @returns {Promise<string>} Processed image URL or original src as fallback
378
+ */
379
+ static async url(src, options = {}) {
380
+ if (!src) return ''
381
+ if (!this.isAvailable()) return src
382
+
383
+ const format = options.format || global.Odac?.Config?.image?.format || null
384
+ const quality = options.quality || global.Odac?.Config?.image?.quality || null
385
+ const opts = {width: options.width || null, height: options.height || null, format, quality}
386
+
387
+ const isDebug = global.Odac?.Config?.debug !== false
388
+ const baseDir = global.__dir || process.cwd()
389
+ const sourcePath = path.join(baseDir, 'public', src)
390
+ let mtime = 0
391
+
392
+ if (!isDebug && this.#mtimeCache.has(src)) {
393
+ mtime = this.#mtimeCache.get(src)
394
+ } else {
395
+ try {
396
+ const stat = await fsPromises.stat(sourcePath)
397
+ mtime = stat.mtimeMs
398
+ if (!isDebug) this.#mtimeCache.set(src, mtime)
399
+ } catch {
400
+ return src
401
+ }
402
+ }
403
+
404
+ const result = await this.process(src, opts, mtime)
405
+ if (!result) return src
406
+
407
+ return `/_odac/img/${result.cacheKey}`
408
+ }
409
+
410
+ /**
411
+ * Compile-time parser that converts `<odac:img>` template tags into
412
+ * `<script:odac>` blocks containing runtime Image.render() calls.
413
+ *
414
+ * Runs in the View#render pipeline BEFORE jsBlocks extraction, so the
415
+ * generated `<script:odac>` blocks are properly protected from template
416
+ * literal escaping — identical to how Form.parse operates.
417
+ *
418
+ * @param {string} content - Raw template HTML
419
+ * @returns {string} Template with `<odac:img>` tags replaced by `<script:odac>` blocks
420
+ */
421
+ static parse(content) {
422
+ return content.replace(/<odac:img\s+([^>]*?)\/?>/g, (fullMatch, attributes) => {
423
+ const attrs = {}
424
+ const attrRegex = /(\w[\w-]*)(?:=(["'])((?:(?!\2).)*)\2|=([^\s>]+))?/g
425
+ let match
426
+ while ((match = attrRegex.exec(attributes))) {
427
+ const key = match[1]
428
+ const value = match[3] !== undefined ? match[3] : match[4] !== undefined ? match[4] : true
429
+ attrs[key] = value
430
+ }
431
+
432
+ if (!attrs.src) return fullMatch
433
+
434
+ let attrsStr = JSON.stringify(attrs)
435
+
436
+ // Unquote dynamic template expressions so they become live JS at runtime
437
+ attrsStr = attrsStr.replace(/"\{\{([\s\S]*?)\}\}"/g, '(await Odac.Var(await $1).html())')
438
+ attrsStr = attrsStr.replace(/"\{!!([\s\S]*?)!!\}"/g, '(await $1)')
439
+
440
+ return `<script:odac>html += await Odac.View.Image.render(${attrsStr});</script:odac>`
441
+ })
442
+ }
443
+
444
+ /**
445
+ * Escapes HTML special characters in attribute values to prevent XSS
446
+ * injection through dynamic template expressions or user-controlled input.
447
+ * @param {string} value - Raw attribute value
448
+ * @returns {string} Escaped value safe for HTML attribute context
449
+ */
450
+ static #escapeAttr(value) {
451
+ if (typeof value !== 'string') return value
452
+ return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
453
+ }
454
+
455
+ /**
456
+ * Renders a standard HTML `<img>` tag from the given attributes,
457
+ * excluding processing-specific attributes (format, quality).
458
+ * All attribute values are HTML-escaped to prevent XSS injection.
459
+ * @param {string} src - The resolved src URL
460
+ * @param {object} attrs - All parsed attributes
461
+ * @param {Set<string>} exclude - Attribute names to exclude from HTML output
462
+ * @returns {string} HTML img tag
463
+ */
464
+ static #renderImgTag(src, attrs, exclude) {
465
+ let tag = `<img src="${this.#escapeAttr(src)}"`
466
+
467
+ // Alphabetical order for deterministic output
468
+ const keys = Object.keys(attrs)
469
+ .filter(k => !exclude.has(k))
470
+ .sort()
471
+ for (const key of keys) {
472
+ const value = attrs[key]
473
+ if (value === true) {
474
+ tag += ` ${key}`
475
+ } else {
476
+ tag += ` ${key}="${this.#escapeAttr(value)}"`
477
+ }
478
+ }
479
+
480
+ tag += '>'
481
+ return tag
482
+ }
483
+
484
+ /**
485
+ * Clears all in-memory caches (processed image index + source mtime).
486
+ * Useful during hot-reload in development mode to pick up re-processed
487
+ * images and detect source file changes immediately.
488
+ */
489
+ static clearCache() {
490
+ this.#cache.clear()
491
+ this.#mtimeCache.clear()
492
+ }
493
+ }
494
+
495
+ module.exports = Image
package/src/View.js CHANGED
@@ -3,6 +3,7 @@ const fs = require('fs')
3
3
  const fsPromises = fs.promises
4
4
  const Form = require('./View/Form')
5
5
  const EarlyHints = require('./View/EarlyHints')
6
+ const Image = require('./View/Image')
6
7
 
7
8
  const TITLE_REGEX = /<title[^>]*>([^<]*)<\/title>/i
8
9
 
@@ -119,7 +120,9 @@ class View {
119
120
  this.#earlyHints = global.Odac.View.EarlyHints
120
121
  }
121
122
  global.Odac.View.Form = Form
123
+ global.Odac.View.Image = Image
122
124
  this.Form = Form
125
+ this.Image = Image
123
126
  }
124
127
 
125
128
  all(name) {
@@ -404,6 +407,7 @@ class View {
404
407
 
405
408
  if (content !== null) {
406
409
  content = Form.parse(content, this.#odac)
410
+ content = Image.parse(content)
407
411
 
408
412
  const jsBlocks = []
409
413
  content = content.replace(/<script:odac([^>]*)>([\s\S]*?)<\/script:odac>/g, (match, attrs, jsContent) => {