From 55409924e081946bdf4bddb638ed4fc3fd267cb1 Mon Sep 17 00:00:00 2001 From: Navneet Agarwal Date: Tue, 14 Apr 2026 16:45:52 +0530 Subject: [PATCH 01/21] 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 02/21] 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 03/21] 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 04/21] 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(); } From 50d7dee185403d9ba8298358bffa123ffbbb36cd Mon Sep 17 00:00:00 2001 From: Alexandre Capt Date: Thu, 16 Apr 2026 16:33:41 +0200 Subject: [PATCH 05/21] feat: aem content (#2689) --- README.md | 2 +- package-lock.json | 155 +++++++++- package.json | 5 + src/cli.js | 3 + src/content/add.cmd.js | 133 +++++++++ src/content/add.js | 47 +++ src/content/clone.cmd.js | 214 ++++++++++++++ src/content/clone.js | 74 +++++ src/content/commit.cmd.js | 57 ++++ src/content/commit.js | 40 +++ src/content/content-git.js | 181 ++++++++++++ src/content/content-metadata-html.js | 321 ++++++++++++++++++++ src/content/content-shared.js | 44 +++ src/content/content.js | 38 +++ src/content/da-api.js | 198 +++++++++++++ src/content/da-auth.js | 209 +++++++++++++ src/content/diff.cmd.js | 123 ++++++++ src/content/diff.js | 45 +++ src/content/merge.cmd.js | 150 ++++++++++ src/content/merge.js | 45 +++ src/content/push.cmd.js | 291 ++++++++++++++++++ src/content/push.js | 58 ++++ src/content/status.cmd.js | 94 ++++++ src/content/status.js | 33 +++ src/server/BaseServer.js | 10 +- src/server/HelixProject.js | 19 ++ src/server/HelixServer.js | 82 ++++++ src/server/MetadataSheetSupport.js | 221 ++++++++++++++ test/content-metadata-html.test.js | 147 ++++++++++ test/content/add.cmd.test.js | 111 +++++++ test/content/clone.cmd.test.js | 288 ++++++++++++++++++ test/content/content-commands.test.js | 373 ++++++++++++++++++++++++ test/content/content-git.test.js | 52 ++++ test/content/content-test-utils.js | 145 +++++++++ test/content/da-api.test.js | 405 ++++++++++++++++++++++++++ test/content/da-auth.test.js | 210 +++++++++++++ test/content/diff.cmd.test.js | 168 +++++++++++ test/content/merge.cmd.test.js | 205 +++++++++++++ test/content/push.cmd.test.js | 279 ++++++++++++++++++ test/content/status.cmd.test.js | 114 ++++++++ test/metadata-sheet-support.test.js | 50 ++++ test/server-html-folder.test.js | 14 +- test/server.test.js | 208 +++++++++++++ 43 files changed, 5646 insertions(+), 15 deletions(-) create mode 100644 src/content/add.cmd.js create mode 100644 src/content/add.js create mode 100644 src/content/clone.cmd.js create mode 100644 src/content/clone.js create mode 100644 src/content/commit.cmd.js create mode 100644 src/content/commit.js create mode 100644 src/content/content-git.js create mode 100644 src/content/content-metadata-html.js create mode 100644 src/content/content-shared.js create mode 100644 src/content/content.js create mode 100644 src/content/da-api.js create mode 100644 src/content/da-auth.js create mode 100644 src/content/diff.cmd.js create mode 100644 src/content/diff.js create mode 100644 src/content/merge.cmd.js create mode 100644 src/content/merge.js create mode 100644 src/content/push.cmd.js create mode 100644 src/content/push.js create mode 100644 src/content/status.cmd.js create mode 100644 src/content/status.js create mode 100644 src/server/MetadataSheetSupport.js create mode 100644 test/content-metadata-html.test.js create mode 100644 test/content/add.cmd.test.js create mode 100644 test/content/clone.cmd.test.js create mode 100644 test/content/content-commands.test.js create mode 100644 test/content/content-git.test.js create mode 100644 test/content/content-test-utils.js create mode 100644 test/content/da-api.test.js create mode 100644 test/content/da-auth.test.js create mode 100644 test/content/diff.cmd.test.js create mode 100644 test/content/merge.cmd.test.js create mode 100644 test/content/push.cmd.test.js create mode 100644 test/content/status.cmd.test.js create mode 100644 test/metadata-sheet-support.test.js diff --git a/README.md b/README.md index b0d287a2c..b80a37f08 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ The `--html-folder` option enables serving HTML files without extensions, useful ``` $ aem up --html-folder drafts # serves at /drafts/* -$ aem up --html-folder content --html-mount / # serves at /* (root) +$ aem up --html-folder html --html-mount / # serves at /* (root) $ aem up --html-folder drafts --html-mount /preview # serves at /preview/* ``` diff --git a/package-lock.json b/package-lock.json index 107c60d5d..3c02ea136 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,12 +14,14 @@ "@adobe/helix-shared-config": "11.1.20", "@adobe/helix-shared-git": "3.0.23", "@adobe/helix-shared-indexer": "2.2.7", + "@adobe/helix-shared-process-queue": "3.1.6", "camelcase": "9.0.0", "chalk-template": "1.1.2", "chokidar": "5.0.0", "compression": "1.8.1", "cookie": "1.1.1", "cookie-parser": "1.4.7", + "diff": "8.0.3", "dotenv": "17.3.1", "express": "5.2.1", "faye-websocket": "0.11.4", @@ -27,6 +29,7 @@ "glob": "13.0.6", "glob-to-regexp": "0.4.1", "hast-util-select": "6.0.4", + "hast-util-to-html": "9.0.5", "http-proxy-agent": "8.0.0", "https-proxy-agent": "8.0.0", "ignore": "7.0.5", @@ -34,6 +37,8 @@ "isomorphic-git": "1.37.4", "jose": "6.2.2", "livereload-js": "4.0.2", + "mime": "4.1.0", + "node-diff3": "3.2.0", "node-fetch": "3.3.2", "open": "11.0.0", "progress": "2.0.3", @@ -163,6 +168,12 @@ "colorette": "^2.0.2" } }, + "node_modules/@adobe/helix-shared-async": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@adobe/helix-shared-async/-/helix-shared-async-2.0.2.tgz", + "integrity": "sha512-I510DKZI7Vf1ikqm9asKN5ZG9oEEx6VQpttdtzM1BGkrXWA7t/QeG6O54TLKGYMXDhdhpH+8kaleS3OfhQyDOQ==", + "license": "Apache-2.0" + }, "node_modules/@adobe/helix-shared-config": { "version": "11.1.20", "resolved": "https://registry.npmjs.org/@adobe/helix-shared-config/-/helix-shared-config-11.1.20.tgz", @@ -217,6 +228,15 @@ "unified": "11.0.5" } }, + "node_modules/@adobe/helix-shared-process-queue": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@adobe/helix-shared-process-queue/-/helix-shared-process-queue-3.1.6.tgz", + "integrity": "sha512-1cYBCDRhlt/NnULO9USyao2udv17jQoAv8QcHHqq52oeYFqsBPKc/Hn4/TRBqZL3hR3pCSvG34rFQOSuYrFGDg==", + "license": "Apache-2.0", + "dependencies": { + "@adobe/helix-shared-async": "2.0.2" + } + }, "node_modules/@adobe/helix-shared-prune": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@adobe/helix-shared-prune/-/helix-shared-prune-1.0.5.tgz", @@ -646,6 +666,7 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -1646,6 +1667,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1766,6 +1788,21 @@ "dev": true, "license": "MIT" }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2048,6 +2085,20 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -3404,10 +3455,9 @@ } }, "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", - "dev": true, + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -3987,6 +4037,7 @@ "integrity": "sha512-sjc7Y8cUD1IlwYcTS9qPSvGjAC8Ne9LctpxKKu3x/1IC9bnOg98Zy6GxEJUfr1NojMgVPlyANXYns8oE2c1TAA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4922,6 +4973,21 @@ "node": ">=14.14" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5964,6 +6030,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -7156,6 +7236,7 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -7664,7 +7745,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", - "dev": true, "funding": [ "https://github.com/sponsors/broofa" ], @@ -7886,6 +7966,16 @@ "node": ">=12" } }, + "node_modules/mocha/node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/mocha/node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -8112,6 +8202,16 @@ "node": ">= 10.13" } }, + "node_modules/node-diff3": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/node-diff3/-/node-diff3-3.2.0.tgz", + "integrity": "sha512-vLh2xJFSyniBLYDEDbXKqD32fQ5vAxmYT4hco8t0EHQ4CQ4BDHhshi7kdvDc6Y1MwGSi1Mhl4unUukPbCayZdw==", + "license": "MIT", + "engines": { + "bun": ">=1.3.0", + "node": ">=18" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -8181,6 +8281,17 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/normalize-url": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.0.tgz", @@ -10032,6 +10143,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11006,7 +11118,8 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-0.0.1.tgz", "integrity": "sha512-fBWNLTBkxkLAhe1AzF1hyXEvuA+N+vV1WMP2D6iiMUblvmOt8Pp5t8zUcgvz7aYA1ldUdxDlgUse15dmcKjkNg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/randombytes": { "version": "2.1.0", @@ -11583,6 +11696,7 @@ "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -11655,6 +11769,20 @@ "node": ">= 6" } }, + "node_modules/semantic-release-discord-bot/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/semantic-release-discord-bot/node_modules/nunjucks": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", @@ -11681,6 +11809,20 @@ } } }, + "node_modules/semantic-release-discord-bot/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/semantic-release/node_modules/@semantic-release/error": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", @@ -13254,6 +13396,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index 1e1dd4ede..b698cdee2 100644 --- a/package.json +++ b/package.json @@ -48,12 +48,14 @@ "@adobe/helix-shared-config": "11.1.20", "@adobe/helix-shared-git": "3.0.23", "@adobe/helix-shared-indexer": "2.2.7", + "@adobe/helix-shared-process-queue": "3.1.6", "camelcase": "9.0.0", "chalk-template": "1.1.2", "chokidar": "5.0.0", "compression": "1.8.1", "cookie": "1.1.1", "cookie-parser": "1.4.7", + "diff": "8.0.3", "dotenv": "17.3.1", "express": "5.2.1", "faye-websocket": "0.11.4", @@ -61,13 +63,16 @@ "glob": "13.0.6", "glob-to-regexp": "0.4.1", "hast-util-select": "6.0.4", + "hast-util-to-html": "9.0.5", "http-proxy-agent": "8.0.0", "https-proxy-agent": "8.0.0", "ignore": "7.0.5", "ini": "6.0.0", "isomorphic-git": "1.37.4", "jose": "6.2.2", + "mime": "4.1.0", "livereload-js": "4.0.2", + "node-diff3": "3.2.0", "node-fetch": "3.3.2", "open": "11.0.0", "progress": "2.0.3", diff --git a/src/cli.js b/src/cli.js index 1e22251aa..24dd6a77f 100755 --- a/src/cli.js +++ b/src/cli.js @@ -112,6 +112,9 @@ export default class CLI { this._commands[cmd] = (await import(`./${cmd}.js`)).default(); } } + if (!this._commands.content) { + this._commands.content = (await import('./content/content.js')).default(); + } } return this; } diff --git a/src/content/add.cmd.js b/src/content/add.cmd.js new file mode 100644 index 000000000..d32092ab7 --- /dev/null +++ b/src/content/add.cmd.js @@ -0,0 +1,133 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import fs from 'fs'; +import path from 'path'; +import fse from 'fs-extra'; +import git from 'isomorphic-git'; +import { CONTENT_DIR, CONFIG_FILE } from './content-shared.js'; + +/** + * Paths for git add are relative to the content repo root. Accept optional + * `content/…` (or `{CONTENT_DIR}/…`) so shells can tab-complete from the project root. + * @param {string} raw + * @returns {string} + */ +export function normalizePathForContentAdd(raw) { + let s = String(raw).trim().replace(/\\/g, '/'); + while (s.startsWith('./')) { + s = s.slice(2); + } + const prefix = `${CONTENT_DIR}/`; + if (s === CONTENT_DIR || s === `${CONTENT_DIR}/`) { + return '.'; + } + if (s.startsWith(prefix)) { + const rest = s.slice(prefix.length); + return rest.length > 0 ? rest : '.'; + } + return s.length > 0 ? s : '.'; +} + +/** + * Whether a repo-relative path is covered by an `aem content add` path (`.` = whole tree). + * @param {string} filepath + * @param {string} scope normalized path from {@link normalizePathForContentAdd} + */ +export function filepathInContentAddScope(filepath, scope) { + if (scope === '.' || scope === '') { + return true; + } + const s = scope.replace(/\/$/, ''); + return filepath === s || filepath.startsWith(`${s}/`); +} + +/** + * isomorphic-git `add` only walks paths that exist on disk, so deletions are never staged. + * Stage removals for tracked files missing from the workdir (same idea as `git add -u` for + * those scopes). + * @param {import('isomorphic-git').FsClient} fsClient + * @param {string} dir content repo root + * @param {string[]} scopes normalized add paths + * @returns {Promise} number of index entries removed + */ +export async function stageDeletionsForContentAddScopes(fsClient, dir, scopes) { + const matrix = await git.statusMatrix({ fs: fsClient, dir }); + let n = 0; + for (const [filepath, head, workdir, stage] of matrix) { + const unstagedDelete = head === 1 && workdir === 0 && stage !== 0; + const inScope = scopes.some((sc) => filepathInContentAddScope(filepath, sc)); + if (unstagedDelete && inScope) { + // eslint-disable-next-line no-await-in-loop + await git.remove({ fs: fsClient, dir, filepath }); + n += 1; + } + } + return n; +} + +function isNotFoundError(err) { + return err?.code === 'NotFoundError' || err?.name === 'NotFoundError'; +} + +export default class AddCommand { + constructor(logger) { + this.log = logger; + this._dir = process.cwd(); + /** @type {string[]} */ + this._paths = ['.']; + } + + withDirectory(dir) { + this._dir = dir; + return this; + } + + /** + * @param {string[]} paths paths relative to content/ (default ["."]) + */ + withPaths(paths) { + this._paths = paths && paths.length > 0 ? paths : ['.']; + return this; + } + + async run() { + const { log } = this; + const contentDir = path.resolve(this._dir, CONTENT_DIR); + const configPath = path.join(contentDir, CONFIG_FILE); + + if (!await fse.pathExists(configPath)) { + throw new Error(`No config found at ${configPath}. Run 'aem content clone' first.`); + } + + const normalized = this._paths.map((p) => normalizePathForContentAdd(p)); + + for (const fp of normalized) { + try { + // eslint-disable-next-line no-await-in-loop + await git.add({ fs, dir: contentDir, filepath: fp }); + } catch (err) { + if (!isNotFoundError(err)) { + throw err; + } + // Deleted directory/file: `add` cannot lstat the path; stage removal if it was tracked. + // eslint-disable-next-line no-await-in-loop + const staged = await stageDeletionsForContentAddScopes(fs, contentDir, [fp]); + if (staged === 0) { + throw err; + } + } + } + // `git add .` never visits missing files — stage tracked deletions under the requested paths. + await stageDeletionsForContentAddScopes(fs, contentDir, normalized); + log.info(`Staged: ${normalized.join(', ')}`); + } +} diff --git a/src/content/add.js b/src/content/add.js new file mode 100644 index 000000000..fc3939fce --- /dev/null +++ b/src/content/add.js @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { getOrCreateLogger } from '../log-common.js'; + +export default function add() { + let executor; + return { + set executor(value) { + executor = value; + }, + command: 'add [files..]', + description: 'Stage changes in content/ (like git add)', + builder: (yargs) => { + yargs + .positional('files', { + describe: + 'Paths under content/ to stage, or the same with a content/ prefix for tab completion (default: all)', + type: 'string', + array: true, + }) + .help(); + }, + handler: async (argv) => { + if (!executor) { + const AddCommand = (await import('./add.cmd.js')).default; + executor = new AddCommand(getOrCreateLogger(argv)); + } + const { files } = argv; + let paths = []; + if (Array.isArray(files)) { + paths = files; + } else if (files) { + paths = [files]; + } + await executor.withPaths(paths).run(); + }, + }; +} diff --git a/src/content/clone.cmd.js b/src/content/clone.cmd.js new file mode 100644 index 000000000..ccf6c9430 --- /dev/null +++ b/src/content/clone.cmd.js @@ -0,0 +1,214 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import fs from 'fs'; +import readline from 'readline'; +import path from 'path'; +import fse from 'fs-extra'; +import git from 'isomorphic-git'; +import processQueue from '@adobe/helix-shared-process-queue'; +import GitUtils from '../git-utils.js'; +import { prompt } from '../cli-util.js'; +import { DaClient } from './da-api.js'; +import { getValidToken } from './da-auth.js'; +import { + CONTENT_DIR, + CONFIG_FILE, + GIT_AUTHOR, + LARGE_CLONE_FILE_THRESHOLD, + CONTENT_IO_CONCURRENCY, +} from './content-shared.js'; +import { writeSyncedRef, ensureGitIgnored } from './content-git.js'; + +/** + * @param {*} log logger with .warn + * @param {number} fileCount + * @param {boolean} assumeYes + */ +async function confirmLargeCloneIfNeeded(log, fileCount, assumeYes) { + if (fileCount <= LARGE_CLONE_FILE_THRESHOLD) { + return; + } + log.warn( + `This clone lists ${fileCount} files (more than ${LARGE_CLONE_FILE_THRESHOLD.toLocaleString()}). ` + + 'Downloading may take a long time and use substantial disk space.', + ); + if (assumeYes) { + return; + } + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error( + `Large clone (${fileCount} files) needs confirmation. Re-run with --yes, or clone a smaller path with --path.`, + ); + } + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await prompt(rl, 'Do you really want to proceed? [y/N] '); + if (!/^y(es)?$/i.test(String(answer).trim())) { + throw new Error('Clone cancelled.'); + } + } finally { + rl.close(); + } +} + +export default class CloneCommand { + constructor(logger) { + this.log = logger; + this._dir = process.cwd(); + this._force = false; + this._assumeYes = false; + this._rootPath = null; + } + + withDirectory(dir) { + this._dir = dir; + return this; + } + + withToken(token) { + this._token = token; + return this; + } + + withForce(force) { + this._force = force; + return this; + } + + withAssumeYes(yes) { + this._assumeYes = !!yes; + return this; + } + + withRootPath(daPath) { + this._rootPath = daPath; + return this; + } + + async run() { + const { log } = this; + + if (this._rootPath == null) { + throw new Error('Clone root path was not set (internal error).'); + } + + // 1. Resolve org/repo from git remote + const originUrl = await GitUtils.getOriginURL(this._dir); + if (!originUrl) { + throw new Error('No git remote found. Run `aem content clone` inside an AEM project directory.'); + } + const org = originUrl.owner; + const { repo } = originUrl; + log.info(`Cloning content from da.live: ${org}/${repo}${this._rootPath === '/' ? '' : ` @ ${this._rootPath}`}`); + + // 2. Ensure target path is available (do not create content/ until after file count is known) + const contentDir = path.resolve(this._dir, CONTENT_DIR); + if (await fse.pathExists(contentDir)) { + if (!this._force) { + throw new Error(`'${CONTENT_DIR}' already exists. Use --force to overwrite.`); + } + await fse.remove(contentDir); + } + + // 3. Resolve token + const token = await getValidToken(log, this._token, this._dir); + + // 4. Fetch file list (no local content dir required yet) + const client = new DaClient(token); + log.info('Fetching file list...'); + const showDiscoveryProgress = process.stdout.isTTY; + const files = await client.listAll(org, repo, this._rootPath, showDiscoveryProgress + ? (n) => { + process.stdout.write(`\r ${n} file(s) discovered so far...`); + } + : undefined); + if (showDiscoveryProgress) { + process.stdout.write('\n'); + } + log.info(`Found ${files.length} file(s).`); + + await confirmLargeCloneIfNeeded(log, files.length, this._assumeYes); + + // 5. Prepare content directory and project .gitignore + await fse.ensureDir(contentDir); + await ensureGitIgnored(this._dir, CONTENT_DIR); + + log.info('Downloading...'); + + // 6. Download files (bounded concurrency) + const downloadResults = await processQueue( + files, + async (file) => { + const prefix = `/${org}/${repo}`; + if (!file.path.startsWith(prefix)) { + log.warn(` skip (unexpected path, missing org/repo prefix): ${file.path}`); + return { status: 404 }; + } + const daPath = file.path.slice(prefix.length) || '/'; + const localPath = path.join(contentDir, ...daPath.split('/').filter(Boolean)); + try { + const res = await client.getSource(org, repo, daPath); + if (!res) { + log.warn(` skip (not found): ${daPath}`); + return { status: 404 }; + } + const buffer = await res.buffer(); + await fse.ensureDir(path.dirname(localPath)); + await fse.writeFile(localPath, buffer); + log.info(` ✓ ${daPath}`); + return { status: 200, daPath }; + } catch (err) { + log.warn(` ✗ ${daPath}: ${err.message}`); + return { status: err.status || 500 }; + } + }, + CONTENT_IO_CONCURRENCY, + ); + + const downloaded = []; + let errors = 0; + for (const r of downloadResults) { + if (r.status === 200) { + downloaded.push(r.daPath); + } else if (r.status !== 404) { + errors += 1; + } + } + + // 7. Init git repo and commit as baseline + await git.init({ fs, dir: contentDir, defaultBranch: 'main' }); + await fse.writeFile(path.join(contentDir, '.gitignore'), `${CONFIG_FILE}\n`); + for (const daPath of downloaded) { + // eslint-disable-next-line no-await-in-loop + await git.add({ fs, dir: contentDir, filepath: daPath.replace(/^\//, '') }); + } + await git.add({ fs, dir: contentDir, filepath: '.gitignore' }); + await git.commit({ + fs, + dir: contentDir, + message: `clone: ${org}/${repo}${this._rootPath === '/' ? '' : ` (${this._rootPath})`}`, + author: GIT_AUTHOR, + }); + const headOid = await git.resolveRef({ fs, dir: contentDir, ref: 'HEAD' }); + await writeSyncedRef(fs, contentDir, headOid); + + // 8. Write config (not tracked by git) + await fse.writeJson(path.join(contentDir, CONFIG_FILE), { + org, + repo, + rootPath: this._rootPath, + }, { spaces: 2 }); + + log.info(`\nDone. ${downloaded.length} file(s) downloaded${errors > 0 ? `, ${errors} error(s)` : ''}.`); + log.info(`Content saved to ./${CONTENT_DIR}/`); + } +} diff --git a/src/content/clone.js b/src/content/clone.js new file mode 100644 index 000000000..27f7e1665 --- /dev/null +++ b/src/content/clone.js @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { getOrCreateLogger } from '../log-common.js'; +import { normalizeDaPath, LARGE_CLONE_FILE_THRESHOLD } from './content-shared.js'; + +export default function clone() { + let executor; + return { + set executor(value) { + executor = value; + }, + command: 'clone', + description: 'Clone da.live content locally into content/', + builder: (yargs) => { + yargs + .option('path', { + describe: 'da.live folder to clone (e.g. /ca/fr_ca). Omit only when using --all.', + type: 'string', + }) + .option('all', { + describe: 'Clone the entire site content (large). Use instead of --path.', + type: 'boolean', + default: false, + }) + .option('token', { + describe: 'IMS Bearer token for da.live authentication', + type: 'string', + }) + .option('force', { + describe: 'Overwrite existing content/ without prompting', + type: 'boolean', + default: false, + }) + .option('yes', { + alias: 'y', + describe: `Proceed without prompting when the clone has more than ${LARGE_CLONE_FILE_THRESHOLD.toLocaleString()} files`, + type: 'boolean', + default: false, + }) + .check((argv) => { + if (argv.all && argv.path !== undefined && argv.path !== '') { + return 'Do not use --path together with --all.'; + } + if (!argv.all && (argv.path === undefined || argv.path === '')) { + return 'Missing --path. Example: aem content clone --path /ca/fr_ca. Use --all to clone all the content.'; + } + return true; + }) + .help(); + }, + handler: async (argv) => { + if (!executor) { + const CloneCommand = (await import('./clone.cmd.js')).default; + executor = new CloneCommand(getOrCreateLogger(argv)); + } + const rootPath = argv.all ? '/' : normalizeDaPath(argv.path); + await executor + .withToken(argv.token) + .withForce(argv.force) + .withAssumeYes(argv.yes) + .withRootPath(rootPath) + .run(); + }, + }; +} diff --git a/src/content/commit.cmd.js b/src/content/commit.cmd.js new file mode 100644 index 000000000..d00deea52 --- /dev/null +++ b/src/content/commit.cmd.js @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import fs from 'fs'; +import path from 'path'; +import fse from 'fs-extra'; +import git from 'isomorphic-git'; +import { CONTENT_DIR, CONFIG_FILE, GIT_AUTHOR } from './content-shared.js'; + +export default class CommitCommand { + constructor(logger) { + this.log = logger; + this._dir = process.cwd(); + this._message = ''; + } + + withDirectory(dir) { + this._dir = dir; + return this; + } + + withMessage(message) { + this._message = message || ''; + return this; + } + + async run() { + const { log } = this; + const contentDir = path.resolve(this._dir, CONTENT_DIR); + const configPath = path.join(contentDir, CONFIG_FILE); + + if (!await fse.pathExists(configPath)) { + throw new Error(`No config found at ${configPath}. Run 'aem content clone' first.`); + } + + const msg = String(this._message).trim(); + if (!msg) { + throw new Error('Commit message is required. Use -m "your message".'); + } + + const oid = await git.commit({ + fs, + dir: contentDir, + message: msg, + author: GIT_AUTHOR, + }); + log.info(`Committed ${oid.slice(0, 7)}`); + } +} diff --git a/src/content/commit.js b/src/content/commit.js new file mode 100644 index 000000000..13ce5dffd --- /dev/null +++ b/src/content/commit.js @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { getOrCreateLogger } from '../log-common.js'; + +export default function commit() { + let executor; + return { + set executor(value) { + executor = value; + }, + command: 'commit', + description: 'Commit staged changes in content/ (like git commit)', + builder: (yargs) => { + yargs + .option('m', { + alias: 'message', + describe: 'Commit message', + type: 'string', + demandOption: true, + }) + .help(); + }, + handler: async (argv) => { + if (!executor) { + const CommitCommand = (await import('./commit.cmd.js')).default; + executor = new CommitCommand(getOrCreateLogger(argv)); + } + await executor.withMessage(argv.m).run(); + }, + }; +} diff --git a/src/content/content-git.js b/src/content/content-git.js new file mode 100644 index 000000000..630c625ad --- /dev/null +++ b/src/content/content-git.js @@ -0,0 +1,181 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import path from 'path'; +import fse from 'fs-extra'; +import git from 'isomorphic-git'; + +/** Ref pointing at the last commit whose tree was fully synced to da.live. */ +export const DA_SYNCED_REF = 'refs/da/synced'; + +/** + * OID of the last da.live sync. Uses refs/da/synced when present; otherwise infers + * from commit history (newest `push:` then `clone:`) so repos created before the ref + * existed still compare against the correct baseline. + * @param {import('isomorphic-git').FsClient} fs + * @param {string} dir + * @returns {Promise} + */ +export async function resolveSyncedOid(fs, dir) { + try { + return await git.resolveRef({ fs, dir, ref: DA_SYNCED_REF }); + } catch (err) { + if (err && (err.code === 'NotFoundError' || err.name === 'NotFoundError')) { + const commits = await git.log({ fs, dir, depth: 500 }); + for (const c of commits) { + if (String(c.commit.message).startsWith('push:')) { + return c.oid; + } + } + for (const c of commits) { + if (String(c.commit.message).startsWith('clone:')) { + return c.oid; + } + } + return git.resolveRef({ fs, dir, ref: 'HEAD' }); + } + throw err; + } +} + +/** + * @param {import('isomorphic-git').FsClient} fs + * @param {string} dir + * @param {string} oid + * @returns {Promise} + */ +export async function writeSyncedRef(fs, dir, oid) { + await git.writeRef({ + fs, + dir, + ref: DA_SYNCED_REF, + value: oid, + force: true, + }); +} + +/** + * True when the index or working tree differs from HEAD (uncommitted work). + * @param {Array<[string, number, number, number]>} matrix + * @returns {boolean} + */ +export function statusMatrixHasUncommitted(matrix) { + return matrix.some(([, h, w, s]) => !(h === 1 && w === 1 && s === 1)); +} + +/** + * Diff trees at two commits: paths as da.live paths (`/file`). + * @param {import('isomorphic-git').FsClient} fs + * @param {string} dir + * @param {string} baseOid + * @param {string} headOid + * @returns {Promise<{ added: string[], modified: string[], deleted: string[] }>} + */ +export async function diffCommitTrees(fs, dir, baseOid, headOid) { + if (baseOid === headOid) { + return { added: [], modified: [], deleted: [] }; + } + + const baseFiles = new Set(await git.listFiles({ fs, dir, ref: baseOid })); + const headFiles = new Set(await git.listFiles({ fs, dir, ref: headOid })); + + const added = []; + const modified = []; + const deleted = []; + + for (const f of headFiles) { + if (!baseFiles.has(f)) { + added.push(`/${f}`); + } else { + // eslint-disable-next-line no-await-in-loop + const b1 = await git.readBlob({ + fs, + dir, + oid: baseOid, + filepath: f, + }); + // eslint-disable-next-line no-await-in-loop + const b2 = await git.readBlob({ + fs, + dir, + oid: headOid, + filepath: f, + }); + if (b1.oid !== b2.oid) { + modified.push(`/${f}`); + } + } + } + + for (const f of baseFiles) { + if (!headFiles.has(f)) { + deleted.push(`/${f}`); + } + } + + return { added, modified, deleted }; +} + +/** + * Number of commits reachable from `tipOid` before hitting `ancestorOid` (exclusive). + * @param {import('isomorphic-git').FsClient} fs + * @param {string} dir + * @param {string} tipOid + * @param {string} ancestorOid + * @returns {Promise} + */ +export async function countCommitsAhead(fs, dir, tipOid, ancestorOid) { + if (tipOid === ancestorOid) { + return 0; + } + const commits = await git.log({ + fs, + dir, + ref: tipOid, + depth: 5000, + }); + let n = 0; + for (const c of commits) { + if (c.oid === ancestorOid) { + return n; + } + n += 1; + } + return n; +} + +/** + * Committer time in ms for conflict checks (da.live lastModified). + * @param {import('isomorphic-git').FsClient} fs + * @param {string} dir + * @param {string} commitOid + * @returns {Promise} + */ +export async function getCommitCommitterTimeMs(fs, dir, commitOid) { + const { commit } = await git.readCommit({ fs, dir, oid: commitOid }); + return commit.committer.timestamp * 1000; +} + +/** + * Ensures an entry is present in the project .gitignore, creating the file if needed. + * @param {string} projectDir + * @param {string} entry + */ +export async function ensureGitIgnored(projectDir, entry) { + const gitIgnorePath = path.resolve(projectDir, '.gitignore'); + let content = ''; + if (await fse.pathExists(gitIgnorePath)) { + content = await fse.readFile(gitIgnorePath, 'utf-8'); + } + if (!content.split('\n').map((l) => l.trim()).includes(entry)) { + await fse.appendFile(gitIgnorePath, `\n${entry}\n`); + } +} diff --git a/src/content/content-metadata-html.js b/src/content/content-metadata-html.js new file mode 100644 index 000000000..7e5159f63 --- /dev/null +++ b/src/content/content-metadata-html.js @@ -0,0 +1,321 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Transforms da.live-style `