From 55409924e081946bdf4bddb638ed4fc3fd267cb1 Mon Sep 17 00:00:00 2001 From: Navneet Agarwal Date: Tue, 14 Apr 2026 16:45:52 +0530 Subject: [PATCH 1/4] feat: add --local-forms flag to serve forms from local JSON without publishing Adds --local-forms and --local-forms-mount flags to `aem up` so forms can be rendered locally from JSON files without requiring a publish step. The server constructs the standard AEM form page HTML and lets client-side scripts handle rendering. Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- src/server/HelixProject.js | 38 ++++++++++++++++++ src/server/HelixServer.js | 80 ++++++++++++++++++++++++++++++++++++++ src/server/utils.js | 40 +++++++++++++++++++ src/up.cmd.js | 14 ++++++- src/up.js | 12 ++++++ 6 files changed, 184 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 1e1dd4ede..c9b39db9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/aem-cli", - "version": "16.17.1", + "version": "16.17.0-forms-local.1", "description": "AEM CLI", "main": "index.js", "type": "module", diff --git a/src/server/HelixProject.js b/src/server/HelixProject.js index 58f97ae25..f6943e164 100644 --- a/src/server/HelixProject.js +++ b/src/server/HelixProject.js @@ -96,6 +96,25 @@ export class HelixProject extends BaseProject { return this; } + withLocalForms(value) { + if (value) { + if (path.isAbsolute(value) || value.includes('..') || value.startsWith('/')) { + throw new Error(`Invalid local forms folder: ${value} only folders within the current workspace are allowed`); + } + this._localForms = value; + } + return this; + } + + withLocalFormsMount(value) { + this._localFormsMount = value; + return this; + } + + get localForms() { + return this._localForms; + } + get proxyUrl() { return this._proxyUrl; } @@ -146,6 +165,10 @@ export class HelixProject extends BaseProject { const mount = this._htmlMount || `/${this._htmlFolder}`; this._server.withHtmlFolder(this._htmlFolder, mount); } + if (this._localForms) { + const formsMount = this._localFormsMount || '/content/forms/af/'; + this._server.withLocalForms(this._localForms, formsMount); + } await super.init(); this._indexer = new Indexer() .withLogger(this._logger) @@ -218,6 +241,20 @@ export class HelixProject extends BaseProject { } } + async initLocalForms() { + if (this._localForms && this.liveReload) { + const localFormsPath = resolve(this.directory, this._localForms); + try { + await lstat(localFormsPath); + this.log.debug(`Registered local forms folder for live-reload: ${this._localForms}`); + this.liveReload.registerFiles([`${localFormsPath}/**/*.json`], '/content/forms/af/'); + } catch (e) { + this.log.error(`Local forms folder '${this._localForms}' does not exist`); + throw new Error(`Local forms folder '${this._localForms}' does not exist`); + } + } + } + async initHlxIgnore() { if (this._hlxIgnore && this.liveReload) { const hlxIgnorePath = resolve(this.directory, '.hlxignore'); @@ -251,6 +288,7 @@ export class HelixProject extends BaseProject { await this.initHeadHtml(); await this.init404Html(); await this.initHtmlFolder(); + await this.initLocalForms(); await this.initHlxIgnore(); if (this._indexer) { await this._indexer.init(); diff --git a/src/server/HelixServer.js b/src/server/HelixServer.js index a7a5f2ca7..ad00bc700 100644 --- a/src/server/HelixServer.js +++ b/src/server/HelixServer.js @@ -74,6 +74,12 @@ export class HelixServer extends BaseServer { return this._mountPrefix; } + withLocalForms(folder, mount) { + this._localForms = folder; + this._localFormsMount = mount.endsWith('/') ? mount : `${mount}/`; + return this; + } + async handleLogin(req, res) { const userAgent = req.headers['user-agent']?.toLowerCase(); if (userAgent?.includes('safari') && !userAgent?.includes('chrome')) { @@ -325,6 +331,73 @@ export class HelixServer extends BaseServer { return undefined; } + /** + * Local Forms handler - serves form pages from local JSON files + * @param {Express.Request} req request + * @param {Express.Response} res response + * @param {Function} next next middleware + */ + async handleLocalFormsRequest(req, res, next) { + const pathname = req.path; + const formsPrefix = this._localFormsMount; + + if (!pathname.startsWith(formsPrefix)) { + return next(); + } + + const relativePath = pathname.slice(formsPrefix.length); + + // Security: prevent path traversal + if (relativePath.includes('/../') || relativePath.includes('..')) { + return next(); + } + + const jsonFile = path.resolve( + this._project.directory, + this._localForms, + `${relativePath}.json`, + ); + + if (!utils.validatePathSecurity(jsonFile, this._project.directory)) { + return next(); + } + + let formJson; + try { + formJson = await readFile(jsonFile, 'utf-8'); + } catch (e) { + return next(); + } + + const { log } = this; + const liveReload = this._liveReload; + + if (liveReload) { + liveReload.startRequest(req.id, req.url); + } + + // Construct the form page HTML + let htmlContent = utils.generateFormPageHtml(formJson, relativePath); + + if (liveReload) { + htmlContent = utils.injectLiveReloadScript(htmlContent, this); + } + + res.set({ + 'content-type': 'text/html; charset=utf-8', + 'access-control-allow-origin': '*', + }); + res.send(htmlContent); + + if (liveReload) { + liveReload.registerFile(req.id, jsonFile); + liveReload.endRequest(req.id); + } + + log.debug(`served local form from ${jsonFile} for ${req.url}`); + return undefined; + } + /** * Proxy Mode route handler * @param {Express.Request} req request @@ -448,6 +521,13 @@ export class HelixServer extends BaseServer { this.app.post(LOGIN_ACK_ROUTE, express.json(), asyncHandler(this.handleLoginAck.bind(this))); this.app.options(LOGIN_ACK_ROUTE, asyncHandler(this.handleLoginAck.bind(this))); + // Add local forms handler before the general proxy handler + if (this._localForms) { + const escapedMount = this._localFormsMount.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + this.app.get(new RegExp(`^${escapedMount}.*`), asyncHandler(this.handleLocalFormsRequest.bind(this))); + this.log.info(`Serving local forms from folder: ${this._localForms} at ${this._localFormsMount}`); + } + // Add HTML folder handler before the general proxy handler if (this._htmlFolder) { const mountPattern = new RegExp(`^${this._mountPrefix}.*`); diff --git a/src/server/utils.js b/src/server/utils.js index 643d1fff2..c0dd405b1 100644 --- a/src/server/utils.js +++ b/src/server/utils.js @@ -786,6 +786,46 @@ window.LiveReloadOptions = { const fullHead = headHtml + metaTags; return `${fullHead}
${content}
`; }, + + /** + * Generates a complete form page HTML from a form JSON string. + * This produces the same HTML structure that AEM would serve for a published form. + * @param {string} formJson the form JSON content + * @param {string} formName the form name (used for title) + * @returns {string} complete HTML document + */ + generateFormPageHtml(formJson, formName) { + const title = formName.split('/').pop() || 'form'; + // formJson is the raw file content (a JSON object as string). + // The form block expects a JSON-stringified string inside
,
+    // e.g. "{\"id\":...}" — so we stringify once to wrap in quotes and escape.
+    const encodedJson = JSON.stringify(formJson.trim());
+    return `
+
+  
+    ${utils.escapeHtml(title)}
+    
+    
+    
+    
+  
+  
+    
+
+
+
+
+
+
${encodedJson}
+
+
+
+
+
+
+ +`; + }, }; export default Object.freeze(utils); diff --git a/src/up.cmd.js b/src/up.cmd.js index 26b3c81fd..7dec6b7d3 100644 --- a/src/up.cmd.js +++ b/src/up.cmd.js @@ -65,6 +65,16 @@ export default class UpCommand extends AbstractServerCommand { return this; } + withLocalForms(value) { + this._localForms = value; + return this; + } + + withLocalFormsMount(value) { + this._localFormsMount = value; + return this; + } + async doStop() { await super.doStop(); if (this._watcher) { @@ -118,7 +128,9 @@ export default class UpCommand extends AbstractServerCommand { .withSiteToken(this._siteToken) .withCookies(this._cookies) .withHtmlFolder(this._htmlFolder) - .withHtmlMount(this._htmlMount); + .withHtmlMount(this._htmlMount) + .withLocalForms(this._localForms) + .withLocalFormsMount(this._localFormsMount); this.log.info(chalk`{yellow ___ ________ ___ __ __ v${pkgJson.version}}`); this.log.info(chalk`{yellow / | / ____/ |/ / _____(_)___ ___ __ __/ /___ _/ /_____ _____}`); diff --git a/src/up.js b/src/up.js index 558c960e1..7e015a5eb 100644 --- a/src/up.js +++ b/src/up.js @@ -125,6 +125,16 @@ export default function up() { describe: 'URL path where html-folder files are served (e.g., / for root). Defaults to /FOLDER.', type: 'string', }) + .option('local-forms', { + alias: 'localForms', + describe: 'Serve forms from local JSON files in this folder, without requiring publish.', + type: 'string', + }) + .option('local-forms-mount', { + alias: 'localFormsMount', + describe: 'URL path where local forms are served. Defaults to /content/forms/af/.', + type: 'string', + }) .help(); }, @@ -153,6 +163,8 @@ export default function up() { .withCookies(argv.cookies) .withHtmlFolder(argv.htmlFolder) .withHtmlMount(argv.htmlMount) + .withLocalForms(argv.localForms) + .withLocalFormsMount(argv.localFormsMount) .run(); }, }; From 3dd7418ac1d7bf0f6960bcd4c84062955fd65015 Mon Sep 17 00:00:00 2001 From: Navneet Agarwal Date: Wed, 22 Apr 2026 10:09:47 +0530 Subject: [PATCH 2/4] refactor: replace --local-forms with --html-folder JSON support Remove the --local-forms and --local-forms-mount flags and extend the existing --html-folder mechanism to resolve .json files on-the-fly, rendering them as AEM form block HTML via generateFormPageHtml(). Resolution priority: .html > .plain.html > .json Users migrate from: aem up --local-forms forms to: aem up --html-folder forms --html-mount /content/forms/af/ Co-Authored-By: Claude Sonnet 4.6 --- src/server/HelixProject.js | 43 +-------- src/server/HelixServer.js | 107 ++++++---------------- src/up.cmd.js | 14 +-- src/up.js | 13 --- test/server-html-folder.test.js | 152 ++++++++++++++++++++++++++++++++ 5 files changed, 182 insertions(+), 147 deletions(-) diff --git a/src/server/HelixProject.js b/src/server/HelixProject.js index f6943e164..03af7aac4 100644 --- a/src/server/HelixProject.js +++ b/src/server/HelixProject.js @@ -96,25 +96,6 @@ export class HelixProject extends BaseProject { return this; } - withLocalForms(value) { - if (value) { - if (path.isAbsolute(value) || value.includes('..') || value.startsWith('/')) { - throw new Error(`Invalid local forms folder: ${value} only folders within the current workspace are allowed`); - } - this._localForms = value; - } - return this; - } - - withLocalFormsMount(value) { - this._localFormsMount = value; - return this; - } - - get localForms() { - return this._localForms; - } - get proxyUrl() { return this._proxyUrl; } @@ -165,10 +146,6 @@ export class HelixProject extends BaseProject { const mount = this._htmlMount || `/${this._htmlFolder}`; this._server.withHtmlFolder(this._htmlFolder, mount); } - if (this._localForms) { - const formsMount = this._localFormsMount || '/content/forms/af/'; - this._server.withLocalForms(this._localForms, formsMount); - } await super.init(); this._indexer = new Indexer() .withLogger(this._logger) @@ -233,7 +210,10 @@ export class HelixProject extends BaseProject { await lstat(htmlFolderPath); this.log.debug(`Registered HTML folder for live-reload: ${this._htmlFolder}`); // Watch all HTML files in the folder - only .html extension - this.liveReload.registerFiles([`${htmlFolderPath}/**/*.html`], this._server.mountPrefix); + this.liveReload.registerFiles([ + `${htmlFolderPath}/**/*.html`, + `${htmlFolderPath}/**/*.json`, + ], this._server.mountPrefix); } catch (e) { this.log.error(`HTML folder '${this._htmlFolder}' does not exist`); throw new Error(`HTML folder '${this._htmlFolder}' does not exist`); @@ -241,20 +221,6 @@ export class HelixProject extends BaseProject { } } - async initLocalForms() { - if (this._localForms && this.liveReload) { - const localFormsPath = resolve(this.directory, this._localForms); - try { - await lstat(localFormsPath); - this.log.debug(`Registered local forms folder for live-reload: ${this._localForms}`); - this.liveReload.registerFiles([`${localFormsPath}/**/*.json`], '/content/forms/af/'); - } catch (e) { - this.log.error(`Local forms folder '${this._localForms}' does not exist`); - throw new Error(`Local forms folder '${this._localForms}' does not exist`); - } - } - } - async initHlxIgnore() { if (this._hlxIgnore && this.liveReload) { const hlxIgnorePath = resolve(this.directory, '.hlxignore'); @@ -288,7 +254,6 @@ export class HelixProject extends BaseProject { await this.initHeadHtml(); await this.init404Html(); await this.initHtmlFolder(); - await this.initLocalForms(); await this.initHlxIgnore(); if (this._indexer) { await this._indexer.init(); diff --git a/src/server/HelixServer.js b/src/server/HelixServer.js index ad00bc700..ea7937375 100644 --- a/src/server/HelixServer.js +++ b/src/server/HelixServer.js @@ -74,12 +74,6 @@ export class HelixServer extends BaseServer { return this._mountPrefix; } - withLocalForms(folder, mount) { - this._localForms = folder; - this._localFormsMount = mount.endsWith('/') ? mount : `${mount}/`; - return this; - } - async handleLogin(req, res) { const userAgent = req.headers['user-agent']?.toLowerCase(); if (userAgent?.includes('safari') && !userAgent?.includes('chrome')) { @@ -238,6 +232,26 @@ export class HelixServer extends BaseServer { // Neither exists } + // Try .json (form definition files — rendered as form block HTML on-the-fly) + const jsonFile = path.resolve( + this._project.directory, + this._htmlFolder, + `${relativePath}.json`, + ); + + if (!utils.validatePathSecurity(jsonFile, this._project.directory)) { + return null; + } + + try { + const stats = await lstat(jsonFile); + if (stats.isFile()) { + return { file: jsonFile, isJson: true }; + } + } catch (e) { + // .json not found either + } + return null; } @@ -301,9 +315,12 @@ export class HelixServer extends BaseServer { liveReload.startRequest(req.id, req.url); } - // Load content (handle .plain.html transformation) + // Load content (handle .plain.html transformation and .json form rendering) let htmlContent; - if (resolvedFile.isPlain) { + if (resolvedFile.isJson) { + const formJson = await readFile(resolvedFile.file, 'utf-8'); + htmlContent = utils.generateFormPageHtml(formJson, relativePath); + } else if (resolvedFile.isPlain) { htmlContent = await this.transformPlainHtml(resolvedFile.file); } else { htmlContent = await readFile(resolvedFile.file, 'utf-8'); @@ -331,73 +348,6 @@ export class HelixServer extends BaseServer { return undefined; } - /** - * Local Forms handler - serves form pages from local JSON files - * @param {Express.Request} req request - * @param {Express.Response} res response - * @param {Function} next next middleware - */ - async handleLocalFormsRequest(req, res, next) { - const pathname = req.path; - const formsPrefix = this._localFormsMount; - - if (!pathname.startsWith(formsPrefix)) { - return next(); - } - - const relativePath = pathname.slice(formsPrefix.length); - - // Security: prevent path traversal - if (relativePath.includes('/../') || relativePath.includes('..')) { - return next(); - } - - const jsonFile = path.resolve( - this._project.directory, - this._localForms, - `${relativePath}.json`, - ); - - if (!utils.validatePathSecurity(jsonFile, this._project.directory)) { - return next(); - } - - let formJson; - try { - formJson = await readFile(jsonFile, 'utf-8'); - } catch (e) { - return next(); - } - - const { log } = this; - const liveReload = this._liveReload; - - if (liveReload) { - liveReload.startRequest(req.id, req.url); - } - - // Construct the form page HTML - let htmlContent = utils.generateFormPageHtml(formJson, relativePath); - - if (liveReload) { - htmlContent = utils.injectLiveReloadScript(htmlContent, this); - } - - res.set({ - 'content-type': 'text/html; charset=utf-8', - 'access-control-allow-origin': '*', - }); - res.send(htmlContent); - - if (liveReload) { - liveReload.registerFile(req.id, jsonFile); - liveReload.endRequest(req.id); - } - - log.debug(`served local form from ${jsonFile} for ${req.url}`); - return undefined; - } - /** * Proxy Mode route handler * @param {Express.Request} req request @@ -521,13 +471,6 @@ export class HelixServer extends BaseServer { this.app.post(LOGIN_ACK_ROUTE, express.json(), asyncHandler(this.handleLoginAck.bind(this))); this.app.options(LOGIN_ACK_ROUTE, asyncHandler(this.handleLoginAck.bind(this))); - // Add local forms handler before the general proxy handler - if (this._localForms) { - const escapedMount = this._localFormsMount.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - this.app.get(new RegExp(`^${escapedMount}.*`), asyncHandler(this.handleLocalFormsRequest.bind(this))); - this.log.info(`Serving local forms from folder: ${this._localForms} at ${this._localFormsMount}`); - } - // Add HTML folder handler before the general proxy handler if (this._htmlFolder) { const mountPattern = new RegExp(`^${this._mountPrefix}.*`); diff --git a/src/up.cmd.js b/src/up.cmd.js index 7dec6b7d3..26b3c81fd 100644 --- a/src/up.cmd.js +++ b/src/up.cmd.js @@ -65,16 +65,6 @@ export default class UpCommand extends AbstractServerCommand { return this; } - withLocalForms(value) { - this._localForms = value; - return this; - } - - withLocalFormsMount(value) { - this._localFormsMount = value; - return this; - } - async doStop() { await super.doStop(); if (this._watcher) { @@ -128,9 +118,7 @@ export default class UpCommand extends AbstractServerCommand { .withSiteToken(this._siteToken) .withCookies(this._cookies) .withHtmlFolder(this._htmlFolder) - .withHtmlMount(this._htmlMount) - .withLocalForms(this._localForms) - .withLocalFormsMount(this._localFormsMount); + .withHtmlMount(this._htmlMount); this.log.info(chalk`{yellow ___ ________ ___ __ __ v${pkgJson.version}}`); this.log.info(chalk`{yellow / | / ____/ |/ / _____(_)___ ___ __ __/ /___ _/ /_____ _____}`); diff --git a/src/up.js b/src/up.js index 7e015a5eb..6828b1485 100644 --- a/src/up.js +++ b/src/up.js @@ -125,17 +125,6 @@ export default function up() { describe: 'URL path where html-folder files are served (e.g., / for root). Defaults to /FOLDER.', type: 'string', }) - .option('local-forms', { - alias: 'localForms', - describe: 'Serve forms from local JSON files in this folder, without requiring publish.', - type: 'string', - }) - .option('local-forms-mount', { - alias: 'localFormsMount', - describe: 'URL path where local forms are served. Defaults to /content/forms/af/.', - type: 'string', - }) - .help(); }, handler: async (argv) => { @@ -163,8 +152,6 @@ export default function up() { .withCookies(argv.cookies) .withHtmlFolder(argv.htmlFolder) .withHtmlMount(argv.htmlMount) - .withLocalForms(argv.localForms) - .withLocalFormsMount(argv.localFormsMount) .run(); }, }; diff --git a/test/server-html-folder.test.js b/test/server-html-folder.test.js index 1c602ff3b..bc4ccb526 100644 --- a/test/server-html-folder.test.js +++ b/test/server-html-folder.test.js @@ -1423,4 +1423,156 @@ describe('Helix Server - HTML Folder', () => { } }); }); + + describe('JSON form rendering via --html-folder', () => { + it('serves form page from .json file in html-folder', async () => { + const cwd = await setupProject(path.join(__rootdir, 'test', 'fixtures', 'project'), testRoot); + const formsFolder = path.join(cwd, 'forms'); + await fs.mkdir(formsFolder, { recursive: true }); + const formJson = JSON.stringify({ id: 'my-form', items: [] }); + await fs.writeFile(path.join(formsFolder, 'my-form.json'), formJson); + + const project = new HelixProject() + .withCwd(cwd) + .withLogger(console) + .withHttpPort(0) + .withHtmlFolder('forms') + .withHtmlMount('/content/forms/af/'); + + await project.init(); + try { + await project.start(); + + const response = await fetch(`http://127.0.0.1:${project.server.port}/content/forms/af/my-form`); + assert.equal(response.status, 200, 'Should return 200 for JSON-backed form'); + assert.equal(response.headers.get('content-type'), 'text/html; charset=utf-8'); + + const body = await response.text(); + assert.ok(body.includes('class="form"'), 'Response should contain form block markup'); + assert.ok(body.includes('
'), 'Response should contain pre/code with JSON');
+        assert.ok(body.includes('/scripts/aem.js'), 'Response should include aem.js');
+        assert.ok(body.includes('/styles/styles.css'), 'Response should include styles.css');
+        // Verify the JSON content is embedded in the response
+        assert.ok(body.includes('my-form'), 'Response should contain the form id from the JSON');
+      } finally {
+        await project.stop();
+      }
+    });
+
+    it('injects live reload script into .json form response', async () => {
+      const cwd = await setupProject(path.join(__rootdir, 'test', 'fixtures', 'project'), testRoot);
+      const formsFolder = path.join(cwd, 'forms');
+      await fs.mkdir(formsFolder, { recursive: true });
+      await fs.writeFile(path.join(formsFolder, 'my-form.json'), JSON.stringify({ id: 'my-form' }));
+
+      const project = new HelixProject()
+        .withCwd(cwd)
+        .withLogger(console)
+        .withHttpPort(0)
+        .withHtmlFolder('forms')
+        .withHtmlMount('/content/forms/af/')
+        .withLiveReload(true);
+
+      await project.init();
+      try {
+        await project.start();
+
+        const response = await fetch(`http://127.0.0.1:${project.server.port}/content/forms/af/my-form`);
+        assert.equal(response.status, 200);
+
+        const body = await response.text();
+        assert.ok(body.includes('class="form"'), 'Response should contain form block markup');
+        assert.ok(body.includes('livereload'), 'Response should include live reload script');
+        assert.ok(body.includes('LiveReload'), 'Response should include LiveReload functionality');
+      } finally {
+        await project.stop();
+      }
+    });
+
+    it('.html takes precedence over .json', async () => {
+      const cwd = await setupProject(path.join(__rootdir, 'test', 'fixtures', 'project'), testRoot);
+      const formsFolder = path.join(cwd, 'forms');
+      await fs.mkdir(formsFolder, { recursive: true });
+      await fs.writeFile(path.join(formsFolder, 'my-page.html'), 'HTML wins');
+      await fs.writeFile(path.join(formsFolder, 'my-page.json'), JSON.stringify({ id: 'should-not-be-used' }));
+
+      const project = new HelixProject()
+        .withCwd(cwd)
+        .withLogger(console)
+        .withHttpPort(0)
+        .withHtmlFolder('forms');
+
+      await project.init();
+      try {
+        await project.start();
+
+        const response = await fetch(`http://127.0.0.1:${project.server.port}/forms/my-page`);
+        assert.equal(response.status, 200);
+        const body = await response.text();
+        assert.ok(body.includes('HTML wins'), '.html file should be served, not .json');
+        assert.ok(!body.includes('class="form"'), 'Should not render as form block');
+      } finally {
+        await project.stop();
+      }
+    });
+
+    it('.plain.html takes precedence over .json', async () => {
+      const cwd = await setupProject(path.join(__rootdir, 'test', 'fixtures', 'project'), testRoot);
+      const formsFolder = path.join(cwd, 'forms');
+      await fs.mkdir(formsFolder, { recursive: true });
+      await fs.writeFile(path.join(formsFolder, 'my-page.plain.html'), '
Plain HTML wins
'); + await fs.writeFile(path.join(formsFolder, 'my-page.json'), JSON.stringify({ id: 'should-not-be-used' })); + + nock('https://main--foo--bar.aem.page') + .get('/head.html') + .reply(404); + + const project = new HelixProject() + .withCwd(cwd) + .withLogger(console) + .withHttpPort(0) + .withProxyUrl('https://main--foo--bar.aem.page/') + .withHtmlFolder('forms'); + + await project.init(); + try { + await project.start(); + + const response = await fetch(`http://127.0.0.1:${project.server.port}/forms/my-page`); + assert.equal(response.status, 200); + const body = await response.text(); + assert.ok(body.includes('Plain HTML wins'), '.plain.html should be served, not .json'); + assert.ok(!body.includes('class="form"'), 'Should not render as form block'); + } finally { + await project.stop(); + } + }); + + it('serves .json from nested directory', async () => { + const cwd = await setupProject(path.join(__rootdir, 'test', 'fixtures', 'project'), testRoot); + const nestedFolder = path.join(cwd, 'forms', 'nested'); + await fs.mkdir(nestedFolder, { recursive: true }); + const formJson = JSON.stringify({ id: 'nested-form' }); + await fs.writeFile(path.join(nestedFolder, 'sub-form.json'), formJson); + + const project = new HelixProject() + .withCwd(cwd) + .withLogger(console) + .withHttpPort(0) + .withHtmlFolder('forms') + .withHtmlMount('/content/forms/af/'); + + await project.init(); + try { + await project.start(); + + const response = await fetch(`http://127.0.0.1:${project.server.port}/content/forms/af/nested/sub-form`); + assert.equal(response.status, 200, 'Should serve nested JSON form'); + const body = await response.text(); + assert.ok(body.includes('class="form"'), 'Response should contain form block markup'); + } finally { + await project.stop(); + } + }); + }); }); From 4c75ad963a111821318bf4199b40a6bf7f9c3604 Mon Sep 17 00:00:00 2001 From: Navneet Agarwal Date: Wed, 22 Apr 2026 10:10:27 +0530 Subject: [PATCH 3/4] chore: revert version to 16.17.1 Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c9b39db9d..1e1dd4ede 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/aem-cli", - "version": "16.17.0-forms-local.1", + "version": "16.17.1", "description": "AEM CLI", "main": "index.js", "type": "module", From 42c8ad99d484849d8f6ad2860758744ad583ae7a Mon Sep 17 00:00:00 2001 From: Navneet Agarwal Date: Thu, 23 Apr 2026 17:00:47 +0530 Subject: [PATCH 4/4] feat: serve static files from --html-folder Extend the --html-folder handler to serve files with extensions (e.g. .json) as-is from the folder. This enables the anchor tag pattern for forms: an HTML page references a local JSON file via , and the form block JS fetches it at runtime. Removes the JSON-to-HTML transform (generateFormPageHtml) since the server should not perform markup-specific rendering. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/server/HelixServer.js | 80 +++++++++++------ src/server/utils.js | 40 --------- test/server-html-folder.test.js | 153 +++++++++++++++----------------- 3 files changed, 129 insertions(+), 144 deletions(-) diff --git a/src/server/HelixServer.js b/src/server/HelixServer.js index ea7937375..737779512 100644 --- a/src/server/HelixServer.js +++ b/src/server/HelixServer.js @@ -232,26 +232,6 @@ export class HelixServer extends BaseServer { // Neither exists } - // Try .json (form definition files — rendered as form block HTML on-the-fly) - const jsonFile = path.resolve( - this._project.directory, - this._htmlFolder, - `${relativePath}.json`, - ); - - if (!utils.validatePathSecurity(jsonFile, this._project.directory)) { - return null; - } - - try { - const stats = await lstat(jsonFile); - if (stats.isFile()) { - return { file: jsonFile, isJson: true }; - } - } catch (e) { - // .json not found either - } - return null; } @@ -304,6 +284,10 @@ export class HelixServer extends BaseServer { // Resolve which file to serve (.html or .plain.html) const resolvedFile = await this.resolveHtmlFolderFile(relativePath); if (!resolvedFile) { + // Serve static assets (e.g. .json) from the html-folder as-is + if (relativePath.includes('.')) { + return this.serveHtmlFolderStaticFile(req, res, next, relativePath); + } return next(); } @@ -315,12 +299,9 @@ export class HelixServer extends BaseServer { liveReload.startRequest(req.id, req.url); } - // Load content (handle .plain.html transformation and .json form rendering) + // Load content (handle .plain.html transformation) let htmlContent; - if (resolvedFile.isJson) { - const formJson = await readFile(resolvedFile.file, 'utf-8'); - htmlContent = utils.generateFormPageHtml(formJson, relativePath); - } else if (resolvedFile.isPlain) { + if (resolvedFile.isPlain) { htmlContent = await this.transformPlainHtml(resolvedFile.file); } else { htmlContent = await readFile(resolvedFile.file, 'utf-8'); @@ -348,6 +329,55 @@ export class HelixServer extends BaseServer { return undefined; } + /** + * Serves static files from the HTML folder as-is + * @param {Express.Request} req request + * @param {Express.Response} res response + * @param {Function} next next middleware + * @param {string} relativePath path relative to HTML folder + */ + async serveHtmlFolderStaticFile(req, res, next, relativePath) { + if (relativePath.includes('/../') || relativePath.includes('..')) { + return next(); + } + + const filePath = path.resolve( + this._project.directory, + this._htmlFolder, + relativePath, + ); + + if (!utils.validatePathSecurity( + filePath, + this._project.directory, + )) { + return next(); + } + + try { + const stats = await lstat(filePath); + if (!stats.isFile()) { + return next(); + } + } catch (e) { + return next(); + } + + const sendFile = promisify(res.sendFile).bind(res); + try { + await sendFile(filePath, { + dotfiles: 'allow', + headers: { 'access-control-allow-origin': '*' }, + }); + this.log.debug( + `served static file ${filePath} for ${req.url}`, + ); + } catch (e) { + return next(); + } + return undefined; + } + /** * Proxy Mode route handler * @param {Express.Request} req request diff --git a/src/server/utils.js b/src/server/utils.js index c0dd405b1..643d1fff2 100644 --- a/src/server/utils.js +++ b/src/server/utils.js @@ -786,46 +786,6 @@ window.LiveReloadOptions = { const fullHead = headHtml + metaTags; return `${fullHead}
${content}
`; }, - - /** - * Generates a complete form page HTML from a form JSON string. - * This produces the same HTML structure that AEM would serve for a published form. - * @param {string} formJson the form JSON content - * @param {string} formName the form name (used for title) - * @returns {string} complete HTML document - */ - generateFormPageHtml(formJson, formName) { - const title = formName.split('/').pop() || 'form'; - // formJson is the raw file content (a JSON object as string). - // The form block expects a JSON-stringified string inside
,
-    // e.g. "{\"id\":...}" — so we stringify once to wrap in quotes and escape.
-    const encodedJson = JSON.stringify(formJson.trim());
-    return `
-
-  
-    ${utils.escapeHtml(title)}
-    
-    
-    
-    
-  
-  
-    
-
-
-
-
-
-
${encodedJson}
-
-
-
-
-
-
- -`; - }, }; export default Object.freeze(utils); diff --git a/test/server-html-folder.test.js b/test/server-html-folder.test.js index bc4ccb526..cab59c8d6 100644 --- a/test/server-html-folder.test.js +++ b/test/server-html-folder.test.js @@ -1424,77 +1424,57 @@ describe('Helix Server - HTML Folder', () => { }); }); - describe('JSON form rendering via --html-folder', () => { - it('serves form page from .json file in html-folder', async () => { - const cwd = await setupProject(path.join(__rootdir, 'test', 'fixtures', 'project'), testRoot); + describe('Static file serving via --html-folder', () => { + it('serves .json files as-is from html-folder', async () => { + const cwd = await setupProject( + path.join(__rootdir, 'test', 'fixtures', 'project'), + testRoot, + ); const formsFolder = path.join(cwd, 'forms'); await fs.mkdir(formsFolder, { recursive: true }); const formJson = JSON.stringify({ id: 'my-form', items: [] }); - await fs.writeFile(path.join(formsFolder, 'my-form.json'), formJson); - - const project = new HelixProject() - .withCwd(cwd) - .withLogger(console) - .withHttpPort(0) - .withHtmlFolder('forms') - .withHtmlMount('/content/forms/af/'); - - await project.init(); - try { - await project.start(); - - const response = await fetch(`http://127.0.0.1:${project.server.port}/content/forms/af/my-form`); - assert.equal(response.status, 200, 'Should return 200 for JSON-backed form'); - assert.equal(response.headers.get('content-type'), 'text/html; charset=utf-8'); - - const body = await response.text(); - assert.ok(body.includes('class="form"'), 'Response should contain form block markup'); - assert.ok(body.includes('
'), 'Response should contain pre/code with JSON');
-        assert.ok(body.includes('/scripts/aem.js'), 'Response should include aem.js');
-        assert.ok(body.includes('/styles/styles.css'), 'Response should include styles.css');
-        // Verify the JSON content is embedded in the response
-        assert.ok(body.includes('my-form'), 'Response should contain the form id from the JSON');
-      } finally {
-        await project.stop();
-      }
-    });
-
-    it('injects live reload script into .json form response', async () => {
-      const cwd = await setupProject(path.join(__rootdir, 'test', 'fixtures', 'project'), testRoot);
-      const formsFolder = path.join(cwd, 'forms');
-      await fs.mkdir(formsFolder, { recursive: true });
-      await fs.writeFile(path.join(formsFolder, 'my-form.json'), JSON.stringify({ id: 'my-form' }));
+      await fs.writeFile(
+        path.join(formsFolder, 'my-form.json'),
+        formJson,
+      );
 
       const project = new HelixProject()
         .withCwd(cwd)
         .withLogger(console)
         .withHttpPort(0)
-        .withHtmlFolder('forms')
-        .withHtmlMount('/content/forms/af/')
-        .withLiveReload(true);
+        .withHtmlFolder('forms');
 
       await project.init();
       try {
         await project.start();
 
-        const response = await fetch(`http://127.0.0.1:${project.server.port}/content/forms/af/my-form`);
+        const response = await fetch(
+          `http://127.0.0.1:${project.server.port}/forms/my-form.json`,
+        );
         assert.equal(response.status, 200);
+        assert.ok(
+          response.headers.get('content-type').includes('application/json'),
+          'Should serve with JSON content-type',
+        );
 
-        const body = await response.text();
-        assert.ok(body.includes('class="form"'), 'Response should contain form block markup');
-        assert.ok(body.includes('livereload'), 'Response should include live reload script');
-        assert.ok(body.includes('LiveReload'), 'Response should include LiveReload functionality');
+        const body = await response.json();
+        assert.equal(body.id, 'my-form');
       } finally {
         await project.stop();
       }
     });
 
-    it('.html takes precedence over .json', async () => {
-      const cwd = await setupProject(path.join(__rootdir, 'test', 'fixtures', 'project'), testRoot);
-      const formsFolder = path.join(cwd, 'forms');
-      await fs.mkdir(formsFolder, { recursive: true });
-      await fs.writeFile(path.join(formsFolder, 'my-page.html'), 'HTML wins');
-      await fs.writeFile(path.join(formsFolder, 'my-page.json'), JSON.stringify({ id: 'should-not-be-used' }));
+    it('serves static files from nested directories', async () => {
+      const cwd = await setupProject(
+        path.join(__rootdir, 'test', 'fixtures', 'project'),
+        testRoot,
+      );
+      const nested = path.join(cwd, 'forms', 'nested');
+      await fs.mkdir(nested, { recursive: true });
+      await fs.writeFile(
+        path.join(nested, 'data.json'),
+        JSON.stringify({ id: 'nested' }),
+      );
 
       const project = new HelixProject()
         .withCwd(cwd)
@@ -1506,54 +1486,67 @@ describe('Helix Server - HTML Folder', () => {
       try {
         await project.start();
 
-        const response = await fetch(`http://127.0.0.1:${project.server.port}/forms/my-page`);
+        const response = await fetch(
+          `http://127.0.0.1:${project.server.port}/forms/nested/data.json`,
+        );
         assert.equal(response.status, 200);
-        const body = await response.text();
-        assert.ok(body.includes('HTML wins'), '.html file should be served, not .json');
-        assert.ok(!body.includes('class="form"'), 'Should not render as form block');
+        const body = await response.json();
+        assert.equal(body.id, 'nested');
       } finally {
         await project.stop();
       }
     });
 
-    it('.plain.html takes precedence over .json', async () => {
-      const cwd = await setupProject(path.join(__rootdir, 'test', 'fixtures', 'project'), testRoot);
+    it('blocks path traversal for static files', async () => {
+      const cwd = await setupProject(
+        path.join(__rootdir, 'test', 'fixtures', 'project'),
+        testRoot,
+      );
       const formsFolder = path.join(cwd, 'forms');
       await fs.mkdir(formsFolder, { recursive: true });
-      await fs.writeFile(path.join(formsFolder, 'my-page.plain.html'), '
Plain HTML wins
'); - await fs.writeFile(path.join(formsFolder, 'my-page.json'), JSON.stringify({ id: 'should-not-be-used' })); - - nock('https://main--foo--bar.aem.page') - .get('/head.html') - .reply(404); + await fs.writeFile( + path.join(formsFolder, 'safe.json'), + '{}', + ); + await fs.writeFile( + path.join(cwd, 'secret.json'), + '{"secret":true}', + ); const project = new HelixProject() .withCwd(cwd) .withLogger(console) .withHttpPort(0) - .withProxyUrl('https://main--foo--bar.aem.page/') .withHtmlFolder('forms'); await project.init(); try { await project.start(); - const response = await fetch(`http://127.0.0.1:${project.server.port}/forms/my-page`); - assert.equal(response.status, 200); - const body = await response.text(); - assert.ok(body.includes('Plain HTML wins'), '.plain.html should be served, not .json'); - assert.ok(!body.includes('class="form"'), 'Should not render as form block'); + const response = await fetch( + `http://127.0.0.1:${project.server.port}/forms/../secret.json`, + ); + assert.notEqual( + response.status, + 200, + 'Should not serve files outside html-folder', + ); } finally { await project.stop(); } }); - it('serves .json from nested directory', async () => { - const cwd = await setupProject(path.join(__rootdir, 'test', 'fixtures', 'project'), testRoot); - const nestedFolder = path.join(cwd, 'forms', 'nested'); - await fs.mkdir(nestedFolder, { recursive: true }); - const formJson = JSON.stringify({ id: 'nested-form' }); - await fs.writeFile(path.join(nestedFolder, 'sub-form.json'), formJson); + it('serves static files with custom mount', async () => { + const cwd = await setupProject( + path.join(__rootdir, 'test', 'fixtures', 'project'), + testRoot, + ); + const formsFolder = path.join(cwd, 'forms'); + await fs.mkdir(formsFolder, { recursive: true }); + await fs.writeFile( + path.join(formsFolder, 'xpl.json'), + JSON.stringify({ id: 'xpl-form' }), + ); const project = new HelixProject() .withCwd(cwd) @@ -1566,10 +1559,12 @@ describe('Helix Server - HTML Folder', () => { try { await project.start(); - const response = await fetch(`http://127.0.0.1:${project.server.port}/content/forms/af/nested/sub-form`); - assert.equal(response.status, 200, 'Should serve nested JSON form'); - const body = await response.text(); - assert.ok(body.includes('class="form"'), 'Response should contain form block markup'); + const response = await fetch( + `http://127.0.0.1:${project.server.port}/content/forms/af/xpl.json`, + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.id, 'xpl-form'); } finally { await project.stop(); }