diff --git a/scripts/locales.js b/scripts/locales.js new file mode 100644 index 00000000..2457ec27 --- /dev/null +++ b/scripts/locales.js @@ -0,0 +1,101 @@ +// Single source of truth for locale configuration. +// Extracted from scripts.js so that tools (e.g. paths-config.js) can import +// locale data without triggering the DOM side effects in scripts.js +// (loadStyles, loadPage, etc.). Any locale additions must be made here only. +const LOCALES = { + '': { ietf: 'en-US', tk: 'hah7vzn.css' }, + ae_ar: { ietf: 'ar', tk: 'qxw8hzm.css', dir: 'rtl' }, + ae_en: { ietf: 'en', tk: 'hah7vzn.css', base: '' }, + africa: { ietf: 'en', tk: 'hah7vzn.css', base: '' }, + ar: { ietf: 'es-AR', tk: 'hah7vzn.css', exl: 'es' }, + at: { ietf: 'de-AT', tk: 'hah7vzn.css', exl: 'de', base: 'de' }, + au: { ietf: 'en-AU', tk: 'hah7vzn.css' }, + be_en: { ietf: 'en-BE', tk: 'hah7vzn.css', base: '' }, + be_fr: { ietf: 'fr-BE', tk: 'hah7vzn.css', exl: 'fr', base: 'fr' }, + be_nl: { ietf: 'nl-BE', tk: 'qxw8hzm.css', exl: 'nl' }, + bg: { ietf: 'bg-BG', tk: 'qxw8hzm.css', base: '' }, + br: { ietf: 'pt-BR', tk: 'hah7vzn.css', exl: 'pt-br', base: 'pt' }, + ca_fr: { ietf: 'fr-CA', tk: 'hah7vzn.css', exl: 'fr', base: 'fr' }, + ca: { ietf: 'en-CA', tk: 'hah7vzn.css', base: '' }, + ch_de: { ietf: 'de-CH', tk: 'hah7vzn.css', exl: 'de', base: 'de' }, + ch_fr: { ietf: 'fr-CH', tk: 'hah7vzn.css', exl: 'fr', base: 'fr' }, + ch_it: { ietf: 'it-CH', tk: 'hah7vzn.css', exl: 'it', base: 'it' }, + cl: { ietf: 'es-CL', tk: 'hah7vzn.css', exl: 'es' }, + cn: { ietf: 'zh-CN', tk: 'qxw8hzm', exl: 'zh-hans', base: '' }, + co: { ietf: 'es-CO', tk: 'hah7vzn.css', exl: 'es' }, + cr: { ietf: 'es-419', tk: 'hah7vzn.css' }, + cy_en: { ietf: 'en-CY', tk: 'hah7vzn.css' }, + cz: { ietf: 'cs-CZ', tk: 'qxw8hzm.css', base: '' }, + de: { ietf: 'de-DE', tk: 'hah7vzn.css', exl: 'de' }, + dk: { ietf: 'da-DK', tk: 'qxw8hzm.css', base: '' }, + ec: { ietf: 'es-419', tk: 'hah7vzn.css' }, + ee: { ietf: 'et-EE', tk: 'qxw8hzm.css', base: '' }, + eg_ar: { ietf: 'ar', tk: 'qxw8hzm.css', dir: 'rtl' }, + eg_en: { ietf: 'en-GB', tk: 'hah7vzn.css' }, + el: { ietf: 'el', tk: 'qxw8hzm.css' }, + es: { ietf: 'es-ES', tk: 'hah7vzn.css', exl: 'es' }, + fi: { ietf: 'fi-FI', tk: 'qxw8hzm.css', base: '' }, + fr: { ietf: 'fr-FR', tk: 'hah7vzn.css', exl: 'fr' }, + gr_el: { ietf: 'el', tk: 'qxw8hzm.css' }, + gr_en: { ietf: 'en-GR', tk: 'hah7vzn.css', base: '' }, + gt: { ietf: 'es-419', tk: 'hah7vzn.css' }, + hk_en: { ietf: 'en-HK', tk: 'hah7vzn.css', base: '' }, + hk_zh: { ietf: 'zh-HK', tk: 'jay0ecd', exl: 'zh-hant' }, + hu: { ietf: 'hu-HU', tk: 'qxw8hzm.css' }, + id_en: { ietf: 'en', tk: 'hah7vzn.css', base: '' }, + id_id: { ietf: 'id', tk: 'qxw8hzm.css' }, + ie: { ietf: 'en-GB', tk: 'hah7vzn.css', base: '' }, + il_en: { ietf: 'en-IL', tk: 'hah7vzn.css', base: '' }, + il_he: { ietf: 'he', tk: 'qxw8hzm.css', dir: 'rtl' }, + in_hi: { ietf: 'hi', tk: 'qxw8hzm.css' }, + in: { ietf: 'en-IN', tk: 'hah7vzn.css' }, + it: { ietf: 'it-IT', tk: 'hah7vzn.css', exl: 'it' }, + jp: { ietf: 'ja-JP', tk: 'dvg6awq', exl: 'ja' }, + kr: { ietf: 'ko-KR', tk: 'qjs5sfm', exl: 'ko' }, + kw_ar: { ietf: 'ar', tk: 'qxw8hzm.css', dir: 'rtl' }, + kw_en: { ietf: 'en-GB', tk: 'hah7vzn.css' }, + la: { ietf: 'es-LA', tk: 'hah7vzn.css', exl: 'es', base: 'es' }, + langstore: { ietf: 'en-US', tk: 'hah7vzn.css' }, + lt: { ietf: 'lt-LT', tk: 'qxw8hzm.css' }, + lu_de: { ietf: 'de-LU', tk: 'hah7vzn.css', exl: 'de', base: 'de' }, + lu_en: { ietf: 'en-LU', tk: 'hah7vzn.css', base: '' }, + lu_fr: { ietf: 'fr-LU', tk: 'hah7vzn.css', exl: 'fr', base: 'fr' }, + lv: { ietf: 'lv-LV', tk: 'qxw8hzm.css' }, + mena_ar: { ietf: 'ar', tk: 'qxw8hzm.css', dir: 'rtl' }, + mena_en: { ietf: 'en', tk: 'hah7vzn.css', base: '' }, + mt: { ietf: 'en-MT', tk: 'hah7vzn.css' }, + mx: { ietf: 'es-MX', tk: 'hah7vzn.css', exl: 'es' }, + my_en: { ietf: 'en-GB', tk: 'hah7vzn.css', base: '' }, + my_ms: { ietf: 'ms', tk: 'qxw8hzm.css' }, + ng: { ietf: 'en-GB', tk: 'hah7vzn.css' }, + nl: { ietf: 'nl-NL', tk: 'qxw8hzm.css', exl: 'nl', base: '' }, + no: { ietf: 'no-NO', tk: 'qxw8hzm.css', base: '' }, + nz: { ietf: 'en-GB', tk: 'hah7vzn.css', base: '' }, + pe: { ietf: 'es-PE', tk: 'hah7vzn.css', exl: 'es' }, + ph_en: { ietf: 'en', tk: 'hah7vzn.css', base: '' }, + ph_fil: { ietf: 'fil-PH', tk: 'qxw8hzm.css' }, + pl: { ietf: 'pl-PL', tk: 'qxw8hzm.css', base: '' }, + pr: { ietf: 'es-419', tk: 'hah7vzn.css' }, + pt: { ietf: 'pt-PT', tk: 'hah7vzn.css', exl: 'pt-br' }, + qa_ar: { ietf: 'ar', tk: 'qxw8hzm.css', dir: 'rtl' }, + qa_en: { ietf: 'en-GB', tk: 'hah7vzn.css' }, + ro: { ietf: 'ro-RO', tk: 'qxw8hzm.css', base: '' }, + ru: { ietf: 'ru-RU', tk: 'qxw8hzm.css', base: '' }, + sa_ar: { ietf: 'ar', tk: 'qxw8hzm.css', dir: 'rtl' }, + sa_en: { ietf: 'en', tk: 'hah7vzn.css', base: '' }, + se: { ietf: 'sv-SE', tk: 'qxw8hzm.css', exl: 'sv', base: '' }, + sg: { ietf: 'en-SG', tk: 'hah7vzn.css', base: '' }, + si: { ietf: 'sl-SI', tk: 'qxw8hzm.css', base: '' }, + sk: { ietf: 'sk-SK', tk: 'qxw8hzm.css', base: '' }, + th_en: { ietf: 'en', tk: 'hah7vzn.css', base: '' }, + th_th: { ietf: 'th', tk: 'lqo2bst.css' }, + tr: { ietf: 'tr-TR', tk: 'qxw8hzm.css', base: '' }, + tw: { ietf: 'zh-TW', tk: 'jay0ecd', exl: 'zh-hant', base: '' }, + ua: { ietf: 'uk-UA', tk: 'qxw8hzm.css', base: '' }, + uk: { ietf: 'en-GB', tk: 'hah7vzn.css' }, + vn_en: { ietf: 'en-GB', tk: 'hah7vzn.css', base: '' }, + vn_vi: { ietf: 'vi', tk: 'qxw8hzm.css' }, + za: { ietf: 'en-GB', tk: 'hah7vzn.css' }, +}; + +export default LOCALES; diff --git a/scripts/scripts.js b/scripts/scripts.js index 8b0861cf..39972b00 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -1,3 +1,5 @@ +import LOCALES from './locales.js'; + const STYLES = ['/styles/styles.css']; const CONFIG = { imsClientId: 'bacom', @@ -24,101 +26,7 @@ const CONFIG = { pdfViewerClientId: '16769f4e1e7b4e3b94c1ed23eafb8870', pdfViewerReportSuite: 'adbadobenonacdcprod,adbadobedxprod,adbadobeprototype', }, - locales: { - '': { ietf: 'en-US', tk: 'hah7vzn.css' }, - ae_ar: { ietf: 'ar', tk: 'qxw8hzm.css', dir: 'rtl' }, - ae_en: { ietf: 'en', tk: 'hah7vzn.css', base: '' }, - africa: { ietf: 'en', tk: 'hah7vzn.css', base: '' }, - ar: { ietf: 'es-AR', tk: 'hah7vzn.css', exl: 'es' }, - at: { ietf: 'de-AT', tk: 'hah7vzn.css', exl: 'de', base: 'de' }, - au: { ietf: 'en-AU', tk: 'hah7vzn.css' }, - be_en: { ietf: 'en-BE', tk: 'hah7vzn.css', base: '' }, - be_fr: { ietf: 'fr-BE', tk: 'hah7vzn.css', exl: 'fr', base: 'fr' }, - be_nl: { ietf: 'nl-BE', tk: 'qxw8hzm.css', exl: 'nl' }, - bg: { ietf: 'bg-BG', tk: 'qxw8hzm.css', base: '' }, - br: { ietf: 'pt-BR', tk: 'hah7vzn.css', exl: 'pt-br', base: 'pt' }, - ca_fr: { ietf: 'fr-CA', tk: 'hah7vzn.css', exl: 'fr', base: 'fr' }, - ca: { ietf: 'en-CA', tk: 'hah7vzn.css', base: '' }, - ch_de: { ietf: 'de-CH', tk: 'hah7vzn.css', exl: 'de', base: 'de' }, - ch_fr: { ietf: 'fr-CH', tk: 'hah7vzn.css', exl: 'fr', base: 'fr' }, - ch_it: { ietf: 'it-CH', tk: 'hah7vzn.css', exl: 'it', base: 'it' }, - cl: { ietf: 'es-CL', tk: 'hah7vzn.css', exl: 'es' }, - cn: { ietf: 'zh-CN', tk: 'qxw8hzm', exl: 'zh-hans', base: '' }, - co: { ietf: 'es-CO', tk: 'hah7vzn.css', exl: 'es' }, - cr: { ietf: 'es-419', tk: 'hah7vzn.css' }, - cy_en: { ietf: 'en-CY', tk: 'hah7vzn.css' }, - cz: { ietf: 'cs-CZ', tk: 'qxw8hzm.css', base: '' }, - de: { ietf: 'de-DE', tk: 'hah7vzn.css', exl: 'de' }, - dk: { ietf: 'da-DK', tk: 'qxw8hzm.css', base: '' }, - ec: { ietf: 'es-419', tk: 'hah7vzn.css' }, - ee: { ietf: 'et-EE', tk: 'qxw8hzm.css', base: '' }, - eg_ar: { ietf: 'ar', tk: 'qxw8hzm.css', dir: 'rtl' }, - eg_en: { ietf: 'en-GB', tk: 'hah7vzn.css' }, - el: { ietf: 'el', tk: 'qxw8hzm.css' }, - es: { ietf: 'es-ES', tk: 'hah7vzn.css', exl: 'es' }, - fi: { ietf: 'fi-FI', tk: 'qxw8hzm.css', base: '' }, - fr: { ietf: 'fr-FR', tk: 'hah7vzn.css', exl: 'fr' }, - gr_el: { ietf: 'el', tk: 'qxw8hzm.css' }, - gr_en: { ietf: 'en-GR', tk: 'hah7vzn.css', base: '' }, - gt: { ietf: 'es-419', tk: 'hah7vzn.css' }, - hk_en: { ietf: 'en-HK', tk: 'hah7vzn.css', base: '' }, - hk_zh: { ietf: 'zh-HK', tk: 'jay0ecd', exl: 'zh-hant' }, - hu: { ietf: 'hu-HU', tk: 'qxw8hzm.css' }, - id_en: { ietf: 'en', tk: 'hah7vzn.css', base: '' }, - id_id: { ietf: 'id', tk: 'qxw8hzm.css' }, - ie: { ietf: 'en-GB', tk: 'hah7vzn.css', base: '' }, - il_en: { ietf: 'en-IL', tk: 'hah7vzn.css', base: '' }, - il_he: { ietf: 'he', tk: 'qxw8hzm.css', dir: 'rtl' }, - in_hi: { ietf: 'hi', tk: 'qxw8hzm.css' }, - in: { ietf: 'en-IN', tk: 'hah7vzn.css' }, - it: { ietf: 'it-IT', tk: 'hah7vzn.css', exl: 'it' }, - jp: { ietf: 'ja-JP', tk: 'dvg6awq', exl: 'ja' }, - kr: { ietf: 'ko-KR', tk: 'qjs5sfm', exl: 'ko' }, - kw_ar: { ietf: 'ar', tk: 'qxw8hzm.css', dir: 'rtl' }, - kw_en: { ietf: 'en-GB', tk: 'hah7vzn.css' }, - la: { ietf: 'es-LA', tk: 'hah7vzn.css', exl: 'es', base: 'es' }, - langstore: { ietf: 'en-US', tk: 'hah7vzn.css' }, - lt: { ietf: 'lt-LT', tk: 'qxw8hzm.css' }, - lu_de: { ietf: 'de-LU', tk: 'hah7vzn.css', exl: 'de', base: 'de' }, - lu_en: { ietf: 'en-LU', tk: 'hah7vzn.css', base: '' }, - lu_fr: { ietf: 'fr-LU', tk: 'hah7vzn.css', exl: 'fr', base: 'fr' }, - lv: { ietf: 'lv-LV', tk: 'qxw8hzm.css' }, - mena_ar: { ietf: 'ar', tk: 'qxw8hzm.css', dir: 'rtl' }, - mena_en: { ietf: 'en', tk: 'hah7vzn.css', base: '' }, - mt: { ietf: 'en-MT', tk: 'hah7vzn.css' }, - mx: { ietf: 'es-MX', tk: 'hah7vzn.css', exl: 'es' }, - my_en: { ietf: 'en-GB', tk: 'hah7vzn.css', base: '' }, - my_ms: { ietf: 'ms', tk: 'qxw8hzm.css' }, - ng: { ietf: 'en-GB', tk: 'hah7vzn.css' }, - nl: { ietf: 'nl-NL', tk: 'qxw8hzm.css', exl: 'nl', base: '' }, - no: { ietf: 'no-NO', tk: 'qxw8hzm.css', base: '' }, - nz: { ietf: 'en-GB', tk: 'hah7vzn.css', base: '' }, - pe: { ietf: 'es-PE', tk: 'hah7vzn.css', exl: 'es' }, - ph_en: { ietf: 'en', tk: 'hah7vzn.css', base: '' }, - ph_fil: { ietf: 'fil-PH', tk: 'qxw8hzm.css' }, - pl: { ietf: 'pl-PL', tk: 'qxw8hzm.css', base: '' }, - pr: { ietf: 'es-419', tk: 'hah7vzn.css' }, - pt: { ietf: 'pt-PT', tk: 'hah7vzn.css', exl: 'pt-br' }, - qa_ar: { ietf: 'ar', tk: 'qxw8hzm.css', dir: 'rtl' }, - qa_en: { ietf: 'en-GB', tk: 'hah7vzn.css' }, - ro: { ietf: 'ro-RO', tk: 'qxw8hzm.css', base: '' }, - ru: { ietf: 'ru-RU', tk: 'qxw8hzm.css', base: '' }, - sa_ar: { ietf: 'ar', tk: 'qxw8hzm.css', dir: 'rtl' }, - sa_en: { ietf: 'en', tk: 'hah7vzn.css', base: '' }, - se: { ietf: 'sv-SE', tk: 'qxw8hzm.css', exl: 'sv', base: '' }, - sg: { ietf: 'en-SG', tk: 'hah7vzn.css', base: '' }, - si: { ietf: 'sl-SI', tk: 'qxw8hzm.css', base: '' }, - sk: { ietf: 'sk-SK', tk: 'qxw8hzm.css', base: '' }, - th_en: { ietf: 'en', tk: 'hah7vzn.css', base: '' }, - th_th: { ietf: 'th', tk: 'lqo2bst.css' }, - tr: { ietf: 'tr-TR', tk: 'qxw8hzm.css', base: '' }, - tw: { ietf: 'zh-TW', tk: 'jay0ecd', exl: 'zh-hant', base: '' }, - ua: { ietf: 'uk-UA', tk: 'qxw8hzm.css', base: '' }, - uk: { ietf: 'en-GB', tk: 'hah7vzn.css' }, - vn_en: { ietf: 'en-GB', tk: 'hah7vzn.css', base: '' }, - vn_vi: { ietf: 'vi', tk: 'qxw8hzm.css' }, - za: { ietf: 'en-GB', tk: 'hah7vzn.css' }, - }, + locales: LOCALES, geoRouting: 'on', productionDomain: 'business.adobe.com', prodDomains: ['business.adobe.com', 'www.adobe.com', 'helpx.adobe.com'], diff --git a/test/tools/generator/da-utils.test.html b/test/tools/generator/da-utils.test.html index ecd12cd1..c1aee380 100644 --- a/test/tools/generator/da-utils.test.html +++ b/test/tools/generator/da-utils.test.html @@ -17,7 +17,10 @@ + + + + + + + diff --git a/test/tools/generator/mocks/tree.js b/test/tools/generator/mocks/tree.js new file mode 100644 index 00000000..ba39acce --- /dev/null +++ b/test/tools/generator/mocks/tree.js @@ -0,0 +1,20 @@ +import sinon from 'sinon'; + +export const crawl = sinon.stub(); + +export const setCrawlItems = (items) => { + crawl.resetHistory(); + crawl.callsFake(({ path: crawlPath, callback }) => { + const run = (async () => { + const matching = items.filter((item) => item.path?.startsWith(crawlPath)); + const processed = []; + for (const item of matching) { + // eslint-disable-next-line no-await-in-loop + await callback?.(item); + processed.push(item); + } + return processed; + })(); + return { results: run }; + }); +}; diff --git a/tools/generator/da-utils.js b/tools/generator/da-utils.js index d9c6e15a..d3e2be2e 100644 --- a/tools/generator/da-utils.js +++ b/tools/generator/da-utils.js @@ -2,13 +2,16 @@ /* eslint-disable no-console */ /* eslint-disable import/no-unresolved */ import { daFetch, replaceHtml } from 'da-fetch'; -import { DA_ORIGIN } from 'constants'; -import { ORG, REPO } from './paths-config.js'; +import { + ORG, + REPO, + ADMIN_DA_ORIGIN, +} from './paths-config.js'; function getDaPath(path, isHtml) { const basePath = path.replace(/\.html$/, ''); const htmlPath = isHtml ? `${basePath}.html` : basePath; - return `${DA_ORIGIN}/source/${ORG}/${REPO}${htmlPath}`; + return `${ADMIN_DA_ORIGIN}/source/${ORG}/${REPO}${htmlPath}`; } function getSheetData(json) { @@ -41,17 +44,12 @@ export async function getSheets(path) { export async function getSource(path) { const daPath = getDaPath(path, true); const opts = { method: 'GET', headers: { accept: '*/*' } }; - try { const response = await daFetch(daPath, opts); - if (response.ok) { - const html = await response.text(); - const newParser = new DOMParser(); - const parsedPage = newParser.parseFromString(html, 'text/html'); - - return parsedPage; - } - /* c8 ignore next 5 */ + if (!response.ok) return null; + const html = await response.text(); + return new DOMParser().parseFromString(html, 'text/html'); + /* c8 ignore next 3 */ } catch (error) { console.log(`Error fetching document ${daPath}`, error); } @@ -109,7 +107,7 @@ export function toSheetFormat(data) { } export async function saveSheets(path, data) { - const daPath = `${DA_ORIGIN}/source/${ORG}/${REPO}${path}`; + const daPath = `${ADMIN_DA_ORIGIN}/source/${ORG}/${REPO}${path}`; const formData = new FormData(); const jsonData = toSheetFormat(data); @@ -217,7 +215,7 @@ export async function saveFile(path, file) { export async function checkPath(path) { const parentDir = path.replace(/\/[^/]+$/, ''); const fileName = path.split('/').pop(); - const listUrl = `${DA_ORIGIN}/list/${ORG}/${REPO}${parentDir}`; + const listUrl = `${ADMIN_DA_ORIGIN}/list/${ORG}/${REPO}${parentDir}`; try { const listResponse = await daFetch(listUrl, { method: 'GET' }); if (!listResponse.ok) return false; diff --git a/tools/generator/landing-page.js b/tools/generator/landing-page.js index c63b704e..5c79abde 100644 --- a/tools/generator/landing-page.js +++ b/tools/generator/landing-page.js @@ -11,6 +11,7 @@ import { LIBS } from '../../scripts/scripts.js'; import { createToast, TOAST_TYPES } from './toast/toast.js'; import { STATUS as PATH_STATUS } from './path-input/path-input.js'; import { getSource, saveSource, saveFile, getSheets, checkPath } from './da-utils.js'; +import { appendLogRow, resolvePublisher } from './lpb-log.js'; import { STAGE_ORIGIN, ADMIN_STATUS_URL, @@ -46,12 +47,13 @@ import { } from './form-sections.js'; const withTimeout = (p, ms, def = null) => Promise.race([p, new Promise((r) => { setTimeout(() => r(def), ms); })]); -await withTimeout(DA_SDK, 5000); +const sdk = await withTimeout(DA_SDK, 5000); +const daContext = sdk?.context || null; const style = await getStyle(import.meta.url.split('?')[0]); const searchParams = new URLSearchParams(window.location.search); -const LPB_VERSION = '1.0.0'; +const LPB_VERSION = '1.1.0'; const FORM_STORAGE_KEY = 'landing-page-builder'; const OPTIONS_LOADING = [{ value: 'loading', label: 'Loading...' }]; const OPTIONS_ERROR = [{ value: 'error', label: 'Error loading options' }]; @@ -263,8 +265,15 @@ class LandingPageForm extends LitElement { super.connectedCallback(); if (DEBUG) console.clear(); document.addEventListener('show-toast', this.handleToast); - const token = await initIms(); - this.token = token?.accessToken?.token; + const ims = await initIms(); + this.token = ims?.accessToken?.token; + // DA may pre-seed initIms with only a token (no profile). If so, fetch profile directly. + if (ims && !ims.email && !ims.displayName && window.adobeIMS?.isSignedInUser?.()) { + const profile = await window.adobeIMS.getProfile().catch(() => null); + this.imsDetails = profile ? { ...profile, accessToken: ims.accessToken } : ims; + } else { + this.imsDetails = ims; + } this.loadFormState(); if (!this.form.contentType || !this.form.gated || !this.form.region) this.form.pageName = ''; @@ -722,7 +731,13 @@ class LandingPageForm extends LitElement { placeholders.assetHeadline, placeholders.pdfAsset, ); - generatedPage = addHiddenTable(generatedPage, { name: FORM_STORAGE_KEY, version: LPB_VERSION }, 'page-builder'); + const freshSdk = await DA_SDK.catch(() => null); + const publishedBy = resolvePublisher(freshSdk ?? daContext, this.token, this.imsDetails); + generatedPage = addHiddenTable(generatedPage, { + name: FORM_STORAGE_KEY, + version: LPB_VERSION, + publishedBy, + }, 'page-builder'); if (DEBUG) generatedPage = addHiddenTable(generatedPage, this.form, 'form-data'); try { @@ -730,10 +745,10 @@ class LandingPageForm extends LitElement { await saveSource(this.form.url, generatedPage); if (DEBUG) console.log('[LPB][Response] PUT page ok:', this.form.url); this.requestUpdate(); - return true; + return { success: true, publishedBy }; } catch (error) { if (DEBUG) console.error('[Response] PUT page failed:', this.form.url, error); - return false; + return { success: false }; } } @@ -813,8 +828,8 @@ class LandingPageForm extends LitElement { } showToast(MESSAGES.SAVING_PAGE, TOAST_TYPES.INFO, 5000); - const saveSuccess = await this.savePage(); - if (!saveSuccess) { + const saveResult = await this.savePage(); + if (!saveResult.success) { if (DEBUG) console.error('[Save & Preview] save failed, aborting'); showToast(MESSAGES.SAVE_PAGE_FAILED, TOAST_TYPES.ERROR, 5000); return; @@ -842,6 +857,24 @@ class LandingPageForm extends LitElement { showToast(MESSAGES.PREVIEW_UPDATED, TOAST_TYPES.SUCCESS, 5000); window.open(getCacheBustUrl(STAGE_ORIGIN + this.previewPath), '_blank'); } + if (pageResult.success) { + await this.logPreview(); + } + } + + async logPreview() { + try { + const contentType = this.form.contentType && this.form.gated + ? `${this.form.contentType}-${this.form.gated}`.toLowerCase() + : ''; + await appendLogRow({ + url: this.form.url, + version: LPB_VERSION, + contentType, + }); + } catch (error) { + if (DEBUG) console.warn('[LPB] log append failed:', error); + } } getRequiredFields() { diff --git a/tools/generator/lpb-log.js b/tools/generator/lpb-log.js new file mode 100644 index 00000000..7ea7f268 --- /dev/null +++ b/tools/generator/lpb-log.js @@ -0,0 +1,414 @@ +/* eslint-disable import/no-unresolved */ +import { daFetch } from 'da-fetch'; +import { crawl } from 'https://da.live/nx/public/utils/tree.js'; +import { getSheets, saveSheets } from './da-utils.js'; +import { ORG, REPO, ADMIN_DA_ORIGIN, getHelixResourceStatusUrl, getScanRoots } from './paths-config.js'; + +export const LOG_PATH = '/tools/page-builder/landing-page/data/lpb-log'; +export const SCAN_ROOT = '/resources'; +export const MARKER_SELECTOR = '.table.hide-block.page-builder'; +export const MARKER_NAME = 'landing-page-builder'; + +const REPO_PREFIX = `/${ORG}/${REPO}`; + +function toRepoRelative(path) { + if (!path) return path; + return path.startsWith(REPO_PREFIX) ? path.slice(REPO_PREFIX.length) : path; +} + +function stripHtmlExt(path) { + return path?.replace(/\.html$/, '') ?? path; +} + +function parseBlockRows(rowEls, cellSelector) { + const data = {}; + rowEls.forEach((row) => { + const cells = row.querySelectorAll(cellSelector); + if (cells.length < 2) return; + const key = cells[0].textContent?.trim(); + const value = cells[1].textContent?.trim(); + if (key) data[key] = value; + }); + return data; +} + +export function extractMarker(doc) { + if (!doc?.querySelector) return null; + + // Format 1: Franklin-processed HTML — div with CSS classes (generated by addHiddenTable) + const el = doc.querySelector(MARKER_SELECTOR); + if (el) { + const data = parseBlockRows([...el.querySelectorAll(':scope > div')], ':scope > div'); + if (data.name === MARKER_NAME) return data; + } + + // Format 2: DA authoring table — block name is text in first cell, not a CSS class + for (const table of doc.querySelectorAll('table')) { + const rows = [...table.querySelectorAll('tr')]; + const headerText = rows[0]?.querySelector('td, th')?.textContent?.trim().toLowerCase() ?? ''; + if (rows.length >= 2 && headerText.includes('page-builder')) { + const data = parseBlockRows(rows.slice(1), 'td, th'); + if (data.name === MARKER_NAME) return data; + } + } + + return null; +} + +/** + * Derive a content type from a page path, e.g. /resources/guides/foo -> guide. + * Returns null if the path doesn't match a known bucket. + */ +export function deriveContentType(path) { + if (!path) return null; + const match = path.match(/\/resources\/([^/]+)\//); + if (!match) return null; + const bucket = match[1].toLowerCase(); + const map = { + guides: 'guide', + reports: 'report', + videos: 'video/demo', + infographics: 'infographic', + sdk: 'ungated', + }; + return map[bucket] ?? bucket; +} + +/** Base64url-decode a JWT payload segment (padding is required for atob). */ +export function decodeJwtPayloadSegment(segment) { + if (!segment || typeof segment !== 'string') return null; + const base64 = segment.replace(/-/g, '+').replace(/_/g, '/'); + const pad = (4 - (base64.length % 4)) % 4; + const padded = base64 + '='.repeat(pad); + try { + return JSON.parse(atob(padded)); + } catch { + return null; + } +} + +const ADOBE_ALLOWED_DOMAINS = ['adobe.com', 'adobelogin.com']; + +function isAllowedHost(hostname) { + const host = String(hostname || '').toLowerCase(); + return ADOBE_ALLOWED_DOMAINS.some((domain) => host === domain || host.endsWith(`.${domain}`)); +} + +function isAdobeNamespacedKey(key) { + if (!key || typeof key !== 'string') return false; + try { + const { hostname } = new URL(key); + return !!(hostname && isAllowedHost(hostname)); + } catch { + // Not a URL; fall through to strict token matching. + } + const lower = key.toLowerCase(); + return /(^|[./:@_-])(adobe\.com|adobelogin\.com)([./:@_-]|$)/.test(lower); +} + +function imsPublisherFromPayload(payload) { + if (!payload || typeof payload !== 'object') return null; + if (payload.email) return String(payload.email); + if (payload.preferred_username) return String(payload.preferred_username); + if (payload.name && String(payload.name).trim()) return String(payload.name).trim(); + if (payload.user_id) return String(payload.user_id); + if (payload.sub) return String(payload.sub); + for (const key of Object.keys(payload)) { + if (isAdobeNamespacedKey(key)) { + const v = payload[key]; + if (typeof v === 'string' && v.includes('@')) return v; + if (v && typeof v === 'object') { + const inner = v.email || v.username || v.name || v.userId || v.user_id; + if (inner) return String(inner); + } + } + } + return null; +} + +/** + * Decode an Adobe IMS access token JWT and return a best-effort publisher id. + * Falls back to 'unknown' if the token is opaque or cannot be decoded. + */ +export function publisherFromToken(token) { + if (!token || typeof token !== 'string') return 'unknown'; + const parts = token.split('.'); + if (parts.length < 2) return 'unknown'; + const payload = decodeJwtPayloadSegment(parts[1]); + return imsPublisherFromPayload(payload) || 'unknown'; +} + +/** + * From initIms() / loadIms(): getProfile() merged with accessToken (see da.live ims.js). + */ +export function publisherFromImsDetails(ims) { + if (!ims || ims.anonymous === true) return null; + if (typeof ims.email === 'string' && ims.email.trim()) return ims.email.trim(); + if (typeof ims.displayName === 'string' && ims.displayName.trim()) return ims.displayName.trim(); + if (ims.name) { + if (typeof ims.name === 'string' && ims.name.trim()) return ims.name.trim(); + if (typeof ims.name === 'object') { + const fn = ims.name.first_name || ims.name.first; + const ln = ims.name.last_name || ims.name.last; + const full = ims.name.full_name || ims.name.fullName; + if (full) return String(full).trim(); + if (fn || ln) return [fn, ln].filter(Boolean).join(' ').trim() || null; + } + } + const fn = ims.first_name || ims.firstName; + const ln = ims.last_name || ims.lastName; + if (fn || ln) return [fn, ln].filter(Boolean).join(' ').trim() || null; + if (ims.userId) return String(ims.userId); + if (ims.user_id) return String(ims.user_id); + if (ims.username) return String(ims.username); + if (ims.sub) return String(ims.sub); + return null; +} + +/** + * Resolve publisher for the log row. + * @param {object} sdkOrContext - DA SDK `{ context, token }` or plain `context` (tests / legacy). + * @param {string} [imsAccessToken] - IMS bearer from initIms (used when sdk.token is absent). + * @param {object} [imsDetails] - Full return value of initIms() (getProfile + accessToken). + */ +export function resolvePublisher(sdkOrContext, imsAccessToken, imsDetails) { + const fromImsProfile = publisherFromImsDetails(imsDetails); + if (fromImsProfile) return fromImsProfile; + + const ctx = sdkOrContext?.context ?? sdkOrContext; + const bearer = imsAccessToken ?? sdkOrContext?.token; + const u = ctx?.user ?? sdkOrContext?.user; + + if (typeof u === 'string' && u.trim()) return u.trim(); + if (u && typeof u === 'object') { + const direct = u.email || u.mail || u.username + || u.preferredUsername || u.preferred_username + || u.displayName || u.name || u.givenName || u.given_name + || u.userId || u.user_id || u.id || u.sub; + if (direct) return String(direct); + } + if (ctx?.email) return String(ctx.email); + if (ctx?.userEmail) return String(ctx.userEmail); + if (ctx?.userName) return String(ctx.userName); + + return publisherFromToken(bearer); +} + +async function fetchSourceDoc(repoRelativePath) { + try { + const path = repoRelativePath.startsWith('/') ? repoRelativePath : `/${repoRelativePath}`; + const res = await daFetch(`${ADMIN_DA_ORIGIN}/source/${ORG}/${REPO}${path}`); + if (!res.ok) return { doc: null }; + const doc = new DOMParser().parseFromString(await res.text(), 'text/html'); + return { doc }; + } catch { + return { doc: null }; + } +} + +const EMPTY_STATUS = { publishState: 'unpublished', previewedAt: null, publishedAt: null, previewedBy: null, publishedBy: null }; + +async function fetchPageStatus(repoRelativePath) { + try { + const path = String(repoRelativePath || '').replace(/\.html$/, ''); + const res = await daFetch(getHelixResourceStatusUrl(`${path}.html`)); + if (!res.ok) return EMPTY_STATUS; + const json = await res.json(); + return { + publishState: json.live?.lastModified ? 'published' : 'unpublished', + previewedAt: json.preview?.lastModified + ? new Date(json.preview.lastModified).toISOString() : null, + publishedAt: json.live?.lastModified + ? new Date(json.live.lastModified).toISOString() : null, + previewedBy: json.preview?.lastModifiedBy || null, + publishedBy: json.live?.lastModifiedBy || null, + }; + } catch { + return EMPTY_STATUS; + } +} + +export async function getLog() { + const sheet = await getSheets(LOG_PATH).catch(() => null); + return Array.isArray(sheet?.data) ? sheet.data : []; +} + +/** + * Append or refresh a single row (called from LPB on publish). + * Non-throwing: logs via lana on failure so publish flow is never blocked. + */ +export async function appendLogRow({ url, version, contentType } = {}) { + if (!url) return null; + const normalizedUrl = stripHtmlExt(url); + const now = new Date().toISOString(); + try { + const [existing, status] = await Promise.all([ + getLog(), + fetchPageStatus(normalizedUrl), + ]); + const index = existing.findIndex((row) => row.url === normalizedUrl); + const prev = index >= 0 ? existing[index] : null; + const row = { + url: normalizedUrl, + previewedAt: status.previewedAt || prev?.previewedAt || now, + publishedAt: status.publishedAt || prev?.publishedAt || null, + publishState: status.publishState || prev?.publishState || 'unpublished', + previewedBy: status.previewedBy || prev?.previewedBy || '', + publishedBy: status.publishedBy || prev?.publishedBy || '', + version: version || prev?.version || '', + contentType: contentType || prev?.contentType || deriveContentType(normalizedUrl) || '', + lastSeenAt: now, + status: 'active', + }; + const next = index >= 0 + ? existing.map((r, i) => (i === index ? { ...r, ...row } : r)) + : [...existing, row]; + return await saveSheets(LOG_PATH, next); + } catch (error) { + window.lana?.log?.(`LPB log append failed: ${error?.message || error}`, { severity: 'warning', tags: 'landing-page-builder,lpb-log' }); + return null; + } +} + +/** + * Walk SCAN_ROOT with DA's crawl utility, loading every HTML page and + * extracting the LPB marker table when present. + */ +export async function scanResources({ onProgress, throttle = 10 } = {}) { + const roots = getScanRoots(); + const rootsTotal = roots.length; + let rootsDone = 0; + const htmlPaths = []; + + // Synchronous callback — crawl utilities do not await async callbacks, + // so async I/O here would complete after results resolves and be lost. + const callback = (item) => { + if (item.ext === 'html') htmlPaths.push(item.path); + }; + + const found = []; + let htmlChecked = 0; + let checkCursor = 0; + const CRAWL_BATCH = 3; + const FETCH_BATCH = 60; + + // Check paths found since the last call without blocking the next crawl batch. + // checkCursor is claimed synchronously pre-await so concurrent calls never duplicate work. + const processNewPaths = async () => { + const batch = htmlPaths.slice(checkCursor); + if (!batch.length) return; + checkCursor = htmlPaths.length; + for (let i = 0; i < batch.length; i += FETCH_BATCH) { + const chunk = batch.slice(i, i + FETCH_BATCH); + // eslint-disable-next-line no-await-in-loop + await Promise.all(chunk.map(async (path) => { + const relativePath = toRepoRelative(path); + const { doc } = await fetchSourceDoc(relativePath); + const marker = extractMarker(doc); + if (marker) { + found.push({ + url: stripHtmlExt(relativePath), + version: marker.version ?? '', + contentType: deriveContentType(relativePath) || '', + }); + } + })); + htmlChecked += chunk.length; + onProgress?.({ phase: 'check', htmlChecked, htmlTotal: htmlPaths.length, lpbFound: found.length }); + } + }; + + // Crawl roots in batches, pipelining source checks for paths discovered so far + const checkPromises = []; + for (let bi = 0; bi < roots.length; bi += CRAWL_BATCH) { + const batch = roots.slice(bi, bi + CRAWL_BATCH); + // eslint-disable-next-line no-await-in-loop + await Promise.all(batch.map(async (root) => { + const { results } = crawl({ path: `${REPO_PREFIX}${root}`, callback, throttle }); + await results; + })); + rootsDone += batch.length; + onProgress?.({ phase: 'crawl', rootsDone, rootsTotal, htmlFound: htmlPaths.length, completedRoot: batch[batch.length - 1] }); + // Fire-and-forget check for newly found paths — runs concurrently with the next crawl batch + checkPromises.push(processNewPaths()); + } + await Promise.all(checkPromises); + await processNewPaths(); // drain any paths added during the final crawl batch + + return found; +} + +/** + * Full rebuild: scan the resources folder, reconcile with the current sheet, + * soft-delete rows for pages that no longer carry the marker, and persist. + */ +export async function rebuildLog({ onProgress, throttle = 10 } = {}) { + const [found, existing] = await Promise.all([ + scanResources({ onProgress, throttle }), + getLog(), + ]); + const now = new Date().toISOString(); + const byUrl = new Map(existing.map((row) => [row.url, row])); + const foundUrls = new Set(found.map((row) => row.url)); + + const pageStatuses = await Promise.all(found.map((row) => fetchPageStatus(row.url))); + + const active = found.map((row, i) => { + const prev = byUrl.get(row.url); + const { publishState, previewedAt, publishedAt, previewedBy, publishedBy } = pageStatuses[i]; + return { + url: row.url, + previewedAt, + publishedAt, + publishState, + previewedBy: previewedBy || '', + publishedBy: publishedBy || '', + version: row.version || prev?.version || '', + contentType: row.contentType || prev?.contentType || '', + lastSeenAt: now, + status: 'active', + }; + }); + + const removed = existing + .filter((row) => !foundUrls.has(row.url)) + .map((row) => ({ + ...row, + status: 'removed', + removedAt: row.removedAt || now, + })); + + const next = [...active, ...removed]; + let res; + try { + res = await saveSheets(LOG_PATH, next); + } catch (error) { + const msg = error?.message || String(error); + window.lana?.log?.(`LPB log rebuild save failed: ${msg}`, { severity: 'warning', tags: 'landing-page-builder,lpb-log' }); + return { + rows: next, + active: active.length, + removed: removed.length, + saved: false, + saveError: msg, + }; + } + if (!res?.ok) { + let saveError = `HTTP ${res.status || 'unknown'}`; + try { + const body = await res.text(); + if (body) saveError = `${saveError} — ${body.slice(0, 400)}`; + } catch { + /* ignore */ + } + window.lana?.log?.(`LPB log rebuild save not ok: ${saveError}`, { severity: 'warning', tags: 'landing-page-builder,lpb-log' }); + return { + rows: next, + active: active.length, + removed: removed.length, + saved: false, + saveError, + }; + } + return { rows: next, active: active.length, removed: removed.length, saved: true }; +} diff --git a/tools/generator/paths-config.js b/tools/generator/paths-config.js index c245b06e..655c9772 100644 --- a/tools/generator/paths-config.js +++ b/tools/generator/paths-config.js @@ -1,18 +1,34 @@ +import LOCALES from '../../scripts/locales.js'; + export const ORG = 'adobecom'; export const REPO = 'da-bacom'; export const BRANCH = 'main'; export const STAGE_ORIGIN = 'https://business.stage.adobe.com'; export const CONTENT_ORIGIN = 'https://content.da.live'; +/** DA Admin API (source, list, versionlist) — see https://docs.da.live/developers/api */ +export const ADMIN_DA_ORIGIN = 'https://admin.da.live'; export const ADMIN_ORIGIN = 'https://admin.hlx.page'; export const AEM_PAGE_ORIGIN = `https://${BRANCH}--${REPO}--${ORG}.aem.page`; export const AEM_LIVE_ORIGIN = `https://${BRANCH}--${REPO}--${ORG}.aem.live`; export const CONTENT_PATH_PREFIX = `/${ORG}/${REPO}`; +export const DA_ORIGIN = 'https://da.live'; export const TEMPLATES_BASE_PATH = '/docs/library/templates/'; export const ADMIN_STATUS_URL = `${ADMIN_ORIGIN}/status/${ORG}/${REPO}/${BRANCH}/`; +export function getScanRoots(subPath = '/resources') { + const sub = subPath.startsWith('/') ? subPath : `/${subPath}`; + return Object.keys(LOCALES).map((prefix) => (prefix ? `/${prefix}${sub}` : sub)); +} + +export function getDAEditUrl(repoRelativePath) { + if (!repoRelativePath) return undefined; + const p = repoRelativePath.startsWith('/') ? repoRelativePath : `/${repoRelativePath}`; + return `${DA_ORIGIN}/edit#${CONTENT_PATH_PREFIX}${p}`; +} + export function getPathFromUrl(url) { if (!url || typeof url !== 'string') return url; try { @@ -63,3 +79,19 @@ export function getTemplateLink(name) { export function getAdminPreviewUrl(path) { return `${ADMIN_ORIGIN}/preview/${ORG}/${REPO}/${BRANCH}${path}`; } + +/** + * Helix admin status for one resource (preview/live lastModified, lastModifiedBy). + * @param {string} repoRelativePath e.g. `/resources/guides/foo.html` + */ +export function getHelixResourceStatusUrl(repoRelativePath) { + const p = String(repoRelativePath || '').replace(/^\/+/, ''); + return `${ADMIN_ORIGIN}/status/${ORG}/${REPO}/${BRANCH}/${p}`; +} + +export function getAdminDaVersionListUrl(repoRelativePath) { + let htmlPath = String(repoRelativePath || '').trim(); + if (!htmlPath.startsWith('/')) htmlPath = `/${htmlPath}`; + if (!htmlPath.endsWith('.html')) htmlPath = `${htmlPath.replace(/\.html$/, '')}.html`; + return `${ADMIN_DA_ORIGIN}/versionlist/${ORG}/${REPO}${htmlPath}`; +} diff --git a/tools/lpb-log/lpb-log.css b/tools/lpb-log/lpb-log.css new file mode 100644 index 00000000..dce17780 --- /dev/null +++ b/tools/lpb-log/lpb-log.css @@ -0,0 +1,324 @@ +:root { + --lpb-border: #e1e1e1; + --lpb-border-strong: #c8c8c8; + --lpb-bg: #f8f8f8; + --lpb-surface: #fff; + --lpb-text: #2c2c2c; + --lpb-text-muted: #6e6e6e; + --lpb-primary: #1473e6; + --lpb-primary-hover: #0d66d0; + --lpb-success: #268e6c; + --lpb-warning: #c77800; + --lpb-danger: #d7373f; +} + +html, body { + margin: 0; + padding: 0; + background: var(--lpb-bg); + color: var(--lpb-text); + font-family: adobe-clean, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 14px; + line-height: 1.5; +} + +main { + max-width: 1280px; + margin: 0 auto; + padding: 24px; +} + +.lpb-log-loading { + padding: 48px 0; + text-align: center; + color: var(--lpb-text-muted); +} + +.lpb-header { + background: var(--lpb-surface); + border: 1px solid var(--lpb-border); + border-radius: 8px; + padding: 20px 24px; + margin-bottom: 16px; +} + +.lpb-title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.lpb-header h1 { + font-size: 22px; + font-weight: 700; + margin: 0 0 4px; +} + +.lpb-subtitle { + color: var(--lpb-text-muted); + margin: 0 0 12px; + font-size: 13px; +} + +.lpb-subtitle code { + background: var(--lpb-bg); + padding: 1px 6px; + border-radius: 3px; + font-size: 12px; +} + +.lpb-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.lpb-btn { + display: inline-flex; + align-items: center; + padding: 6px 14px; + border-radius: 16px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: 1px solid transparent; + text-decoration: none; + transition: background-color 0.15s, border-color 0.15s; +} + +.lpb-btn:disabled { + opacity: 0.75; + cursor: not-allowed; +} + +/* Keep disabled actions visible (e.g. CSV when the filtered list is empty). */ +.lpb-btn[data-action='download-csv']:disabled { + opacity: 0.85; +} + +.lpb-btn-primary { + background: var(--lpb-primary); + color: #fff; +} + +.lpb-btn-primary:hover:not(:disabled) { + background: var(--lpb-primary-hover); +} + +.lpb-btn-secondary { + background: transparent; + color: var(--lpb-text); + border-color: var(--lpb-border-strong); +} + +.lpb-btn-secondary:hover { + background: var(--lpb-bg); +} + +.lpb-stats { + display: flex; + gap: 8px; + margin: 12px 0 8px; + flex-wrap: wrap; +} + +.lpb-chip { + background: #eaf4ff; + color: var(--lpb-primary); + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; +} + +.lpb-chip-muted { + background: var(--lpb-bg); + color: var(--lpb-text-muted); +} + +.lpb-reconcile { + margin: 10px 0 0; + font-size: 13px; + color: var(--lpb-text); +} + +.lpb-reconcile-muted { + color: var(--lpb-text-muted); +} + +.lpb-reconcile-label { + font-weight: 600; + margin-right: 6px; +} + +.lpb-reconcile-sub { + color: var(--lpb-text-muted); + font-weight: 400; +} + +@keyframes lpb-spin { + to { transform: rotate(360deg); } +} + +.lpb-progress { + margin-top: 8px; + padding: 8px 12px; + background: #eaf4ff; + border-radius: 4px; + font-size: 12px; + color: var(--lpb-primary); + display: flex; + align-items: center; + gap: 8px; +} + +.lpb-progress::before { + content: ''; + flex-shrink: 0; + width: 12px; + height: 12px; + border: 2px solid currentcolor; + border-top-color: transparent; + border-radius: 50%; + animation: lpb-spin 0.7s linear infinite; +} + +.lpb-progress code { + font-size: 11px; + background: transparent; +} + +.lpb-error { + margin-top: 8px; + padding: 8px 12px; + background: #fdecea; + color: var(--lpb-danger); + border-radius: 4px; + font-size: 13px; +} + +@keyframes lpb-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + +.lpb-warning { + margin-top: 8px; + padding: 8px 12px; + background: #fff3e0; + color: #b45309; + border-radius: 4px; + font-size: 13px; + animation: lpb-pulse 2s ease-in-out infinite; +} + +.lpb-filters { + display: flex; + gap: 4px; + margin-top: 12px; + border-bottom: 1px solid var(--lpb-border); + padding-bottom: 0; +} + +.lpb-filter { + background: transparent; + border: none; + padding: 8px 16px; + font-size: 13px; + color: var(--lpb-text-muted); + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; +} + +.lpb-filter.is-active { + color: var(--lpb-primary); + border-bottom-color: var(--lpb-primary); + font-weight: 600; +} + +.lpb-table-wrap { + background: var(--lpb-surface); + border: 1px solid var(--lpb-border); + border-radius: 8px; + overflow: hidden; +} + +.lpb-empty { + padding: 48px 24px; + text-align: center; + color: var(--lpb-text-muted); +} + +.lpb-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.lpb-table th, +.lpb-table td { + padding: 10px 14px; + text-align: left; + vertical-align: top; + border-bottom: 1px solid var(--lpb-border); +} + +.lpb-table th { + background: var(--lpb-bg); + font-weight: 600; + color: var(--lpb-text); + cursor: pointer; + user-select: none; + white-space: nowrap; + position: sticky; + top: 0; +} + +.lpb-table th.sorted { + color: var(--lpb-primary); +} + +.lpb-table tbody tr:hover { + background: #fafbfd; +} + +.lpb-table td.col-url a { + color: var(--lpb-primary); + text-decoration: none; + word-break: break-all; +} + +.lpb-table td.col-url a:hover { + text-decoration: underline; +} + +.lpb-table tr.row-removed td { + color: var(--lpb-text-muted); + text-decoration: line-through; +} + +.lpb-table tr.row-removed td.col-url a { + color: var(--lpb-text-muted); +} + +.status-pill { + display: inline-block; + padding: 2px 10px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.status-active { + background: #eaf7f0; + color: var(--lpb-success); +} + +.status-removed { + background: #fbecec; + color: var(--lpb-danger); +} diff --git a/tools/lpb-log/lpb-log.html b/tools/lpb-log/lpb-log.html new file mode 100644 index 00000000..68b864d7 --- /dev/null +++ b/tools/lpb-log/lpb-log.html @@ -0,0 +1,31 @@ + + + + + LPB Log + + + + + + + + + + +
+
+
Loading landing page log…
+
+
+ + + diff --git a/tools/lpb-log/lpb-log.js b/tools/lpb-log/lpb-log.js new file mode 100644 index 00000000..4e43719f --- /dev/null +++ b/tools/lpb-log/lpb-log.js @@ -0,0 +1,506 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable no-use-before-define */ +import DA_SDK from 'da-sdk'; +import { getLog, rebuildLog } from '../generator/lpb-log.js'; +import { AEM_LIVE_ORIGIN, getDAEditUrl } from '../generator/paths-config.js'; + +const STATUS_LABELS = { active: 'Active', removed: 'Removed' }; +const LS_LAST_REBUILD = 'da-bacom-lpb-log-last-rebuild'; +const COLUMNS = [ + { key: 'url', label: 'Page URL' }, + { key: 'author', label: 'Edit Link' }, + { key: 'previewedAt', label: 'Previewed' }, + { key: 'publishedAt', label: 'Published' }, + { key: 'publishState', label: 'Publish State' }, + { key: 'previewedBy', label: 'Previewed By' }, + { key: 'publishedBy', label: 'Published By' }, + { key: 'contentType', label: 'Content Type' }, +]; +/** Table columns plus audit fields useful in spreadsheets */ +const CSV_COLUMNS = [ + ...COLUMNS, + { key: 'status', label: 'Status' }, + { key: 'lastSeenAt', label: 'Last Seen (sheet)' }, + { key: 'removedAt', label: 'Removed At' }, +]; + +const state = { + rows: [], + filter: 'all', + sortKey: 'publishedAt', + sortDir: 'desc', + scanning: false, + signedIn: false, + progress: '', + error: null, + /** ISO time of last successful Rebuild From Scan (this browser). */ + lastRebuildAt: null, +}; + +const DEFAULT_SORT = { key: 'publishedAt', dir: 'desc' }; + +let root; + +function slugHeaderKey(key) { + return String(key || '') + .toLowerCase() + .replace(/[\s_-]+/g, ''); +} + +/** Sheet JSON may use different column headers than our code (camelCase). */ +function normalizeLogRow(row) { + if (!row || typeof row !== 'object') return row; + const out = { ...row }; + const lastSeenSlugs = new Set(['lastseen', 'lastseenat', 'lastupdate', 'lastupdatedat']); + for (const [k, v] of Object.entries(row)) { + if (v != null && v !== '' && lastSeenSlugs.has(slugHeaderKey(k))) { + out.lastSeenAt = v; + break; + } + } + if (out.status != null && out.status !== '') { + out.status = String(out.status).trim().toLowerCase(); + } + return out; +} + +function readLastRebuildFromStorage() { + try { + return sessionStorage.getItem(LS_LAST_REBUILD) || null; + } catch { + /* Storage can throw (private mode, disabled cookies/storage). Hint is optional. */ + return null; + } +} + +function writeLastRebuildToStorage(iso) { + try { + sessionStorage.setItem(LS_LAST_REBUILD, iso); + } catch { + /* Same as read: optional UX hint only; do not fail rebuild/render. */ + } +} + +function formatDate(value) { + if (!value) return '—'; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return String(value); + return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); +} + +function getRowSortValue(row, key) { + if (key === 'publishedAt') return row.publishedAt || row.previewedAt || ''; + return row[key] ?? ''; +} + +function compareRows(a, b, key, dir) { + const av = getRowSortValue(a, key); + const bv = getRowSortValue(b, key); + const cmp = String(av).localeCompare(String(bv), undefined, { numeric: true, sensitivity: 'base' }); + return dir === 'asc' ? cmp : -cmp; +} + +function getVisibleRows() { + let rows = state.rows.filter((r) => !String(r.url || '').includes('nala')); + if (state.filter === 'published') rows = rows.filter((r) => r.status !== 'removed' && r.publishState === 'published'); + else if (state.filter === 'unpublished') rows = rows.filter((r) => r.status !== 'removed' && r.publishState !== 'published'); + else if (state.filter === 'deleted') rows = rows.filter((r) => r.status === 'removed'); + rows.sort((a, b) => compareRows(a, b, state.sortKey, state.sortDir)); + return rows; +} + +/** After rebuild, active rows share the same `lastSeenAt` (shown as last full scan). */ +function inferLastFullReconcileAt(rows) { + const active = rows.filter((r) => r.status !== 'removed'); + if (active.length === 0) return null; + const times = active.map((r) => r.lastSeenAt).filter(Boolean); + if (times.length !== active.length) return null; + const ms = times.map((t) => new Date(t).getTime()); + if (ms.some((n) => Number.isNaN(n))) return null; + const min = Math.min(...ms); + const max = Math.max(...ms); + if (max - min > 2000) return null; + return times[0]; +} + +function appendReconcileHint(p) { + const strong = document.createElement('strong'); + strong.textContent = 'Rebuild From Scan'; + p.append( + 'No single scan time to show. Active rows were updated at different times ' + + '(for example after individual publishes). Run ', + strong, + ' to refresh every active row at once.', + ); +} + +function createReconcileEl(rows, activeCount, lastRebuildBrowserAt) { + if (lastRebuildBrowserAt) { + const p = document.createElement('p'); + p.className = 'lpb-reconcile'; + const label = document.createElement('span'); + label.className = 'lpb-reconcile-label'; + label.textContent = 'Last rebuild'; + p.append(label, document.createTextNode(' ')); + const time = document.createElement('time'); + time.dateTime = lastRebuildBrowserAt; + time.textContent = formatDate(lastRebuildBrowserAt); + p.append(time); + const sub = document.createElement('span'); + sub.className = 'lpb-reconcile-sub'; + sub.textContent = ' (scan from this browser)'; + p.appendChild(sub); + return p; + } + const reconcileAt = inferLastFullReconcileAt(rows); + if (reconcileAt) { + const p = document.createElement('p'); + p.className = 'lpb-reconcile'; + const label = document.createElement('span'); + label.className = 'lpb-reconcile-label'; + label.textContent = 'Last full reconciliation (scan)'; + p.append(label, document.createTextNode(' ')); + const time = document.createElement('time'); + time.dateTime = reconcileAt; + time.textContent = formatDate(reconcileAt); + p.appendChild(time); + return p; + } + if (activeCount > 0) { + const p = document.createElement('p'); + p.className = 'lpb-reconcile lpb-reconcile-muted'; + appendReconcileHint(p); + return p; + } + return null; +} + +function escapeCsvCell(value) { + const s = value == null ? '' : String(value); + if (/[",\r\n]/.test(s)) { + return `"${s.replace(/"/g, '""')}"`; + } + return s; +} + +function buildCsv(rows) { + const headerLine = CSV_COLUMNS.map((col) => escapeCsvCell(col.label)).join(','); + const dataLines = rows.map((row) => CSV_COLUMNS.map((col) => { + let v = row[col.key]; + if (col.key === 'url') { + const urlPath = String(v ?? ''); + const livePath = urlPath.startsWith('/') ? urlPath : `/${urlPath}`; + v = `${AEM_LIVE_ORIGIN}${livePath}`; + } + if (col.key === 'author') v = getDAEditUrl(row.url) ?? ''; + if (col.key === 'status') v = STATUS_LABELS[v] || v || ''; + return escapeCsvCell(v ?? ''); + }).join(',')); + return [headerLine, ...dataLines].join('\r\n'); +} + +function triggerCsvDownload(filename, text) { + const blob = new Blob([`\uFEFF${text}`], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +function handleDownloadCsv() { + if (state.rows.length === 0) return; + const rows = state.rows.slice().sort((a, b) => compareRows(a, b, state.sortKey, state.sortDir)); + const dateStamp = new Date().toISOString().slice(0, 10); + const filename = `lpb-log-${dateStamp}.csv`; + triggerCsvDownload(filename, buildCsv(rows)); +} + +function rowStatusClass(status) { + const s = String(status || 'active').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '') || 'active'; + return `row-${s}`; +} + +function createTableEl(rows) { + if (rows.length === 0) { + const empty = document.createElement('div'); + empty.className = 'lpb-empty'; + empty.textContent = 'No pages logged yet. Publish a landing page or run a scan.'; + return empty; + } + + const table = document.createElement('table'); + table.className = 'lpb-table'; + + const thead = document.createElement('thead'); + const trHead = document.createElement('tr'); + COLUMNS.forEach((col) => { + const th = document.createElement('th'); + th.dataset.sortKey = col.key; + if (state.sortKey === col.key) { + th.classList.add('sorted'); + th.textContent = `${col.label}${state.sortDir === 'asc' ? ' ▲' : ' ▼'}`; + } else { + th.textContent = col.label; + } + trHead.appendChild(th); + }); + thead.appendChild(trHead); + table.appendChild(thead); + + const tbody = document.createElement('tbody'); + rows.forEach((row) => { + const tr = document.createElement('tr'); + tr.className = rowStatusClass(row.status); + + const urlPath = String(row.url ?? ''); + const livePath = urlPath.startsWith('/') ? urlPath : `/${urlPath}`; + const urlTd = document.createElement('td'); + urlTd.className = 'col-url'; + const a = document.createElement('a'); + a.href = `${AEM_LIVE_ORIGIN}${livePath}`; + a.target = '_blank'; + a.rel = 'noopener'; + a.textContent = urlPath; + urlTd.appendChild(a); + tr.appendChild(urlTd); + + const authorTd = document.createElement('td'); + const editUrl = getDAEditUrl(livePath); + if (editUrl) { + const editA = document.createElement('a'); + editA.href = editUrl; + editA.target = '_blank'; + editA.rel = 'noopener'; + editA.textContent = 'Link'; + authorTd.appendChild(editA); + } + tr.appendChild(authorTd); + + const previewedTd = document.createElement('td'); + previewedTd.textContent = formatDate(row.previewedAt); + tr.appendChild(previewedTd); + + const publishedTd = document.createElement('td'); + publishedTd.textContent = formatDate(row.publishedAt); + tr.appendChild(publishedTd); + + ['publishState', 'previewedBy', 'publishedBy', 'contentType'].forEach((key) => { + const td = document.createElement('td'); + const v = row[key]; + td.textContent = v != null && v !== '' ? String(v) : '—'; + tr.appendChild(td); + }); + + tbody.appendChild(tr); + }); + table.appendChild(tbody); + return table; +} + +function appendStatChip(parent, count, label, muted) { + const span = document.createElement('span'); + span.className = muted ? 'lpb-chip lpb-chip-muted' : 'lpb-chip'; + const strong = document.createElement('strong'); + strong.textContent = String(count); + span.append(strong, document.createTextNode(` ${label}`)); + parent.appendChild(span); +} + +async function loadLog() { + try { + const raw = await getLog(); + state.rows = Array.isArray(raw) ? raw.map(normalizeLogRow) : []; + state.sortKey = DEFAULT_SORT.key; + state.sortDir = DEFAULT_SORT.dir; + } catch (error) { + state.error = `Could not load log: ${error?.message || error}`; + state.rows = []; + } + render(); +} + +async function handleRebuild() { + if (state.scanning || !state.signedIn) return; + state.scanning = true; + state.progress = ''; + state.error = null; + render(); + + try { + let crawlMsg = ''; + let checkMsg = ''; + const result = await rebuildLog({ + onProgress: (p) => { + if (p.phase === 'crawl') { + crawlMsg = `Crawled ${p.rootsDone}/${p.rootsTotal} roots — ${p.completedRoot} (${p.htmlFound} pages found)`; + } else { + checkMsg = `Scanning: ${p.htmlChecked} / ${p.htmlTotal} — ${p.lpbFound} LPB pages found`; + } + state.progress = [crawlMsg, checkMsg].filter(Boolean).join(' · '); + render(); + }, + }); + if (!result.saved) { + throw new Error(result.saveError || 'Save failed (no details)'); + } + const rebuildIso = new Date().toISOString(); + state.lastRebuildAt = rebuildIso; + writeLastRebuildToStorage(rebuildIso); + await loadLog(); + } catch (error) { + state.error = `Rebuild failed: ${error?.message || error}`; + window.lana?.log?.(`LPB log rebuild failed: ${error?.message || error}`, { severity: 'error', tags: 'landing-page-builder,lpb-log' }); + } finally { + state.scanning = false; + state.progress = ''; + render(); + } +} + +function bindEvents() { + root.querySelectorAll('[data-filter]').forEach((btn) => { + btn.addEventListener('click', () => { + state.filter = btn.dataset.filter; + render(); + }); + }); + + root.querySelectorAll('[data-sort-key]').forEach((th) => { + th.addEventListener('click', () => { + const key = th.dataset.sortKey; + if (state.sortKey === key) { + state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc'; + } else { + state.sortKey = key; + state.sortDir = key.endsWith('At') ? 'desc' : 'asc'; + } + render(); + }); + }); + + const rebuildBtn = root.querySelector('[data-action="rebuild"]'); + if (rebuildBtn) rebuildBtn.addEventListener('click', handleRebuild); + + const csvBtn = root.querySelector('[data-action="download-csv"]'); + if (csvBtn) csvBtn.addEventListener('click', handleDownloadCsv); +} + +function render() { + const rows = getVisibleRows(); + const total = state.rows.length; + const active = state.rows.filter((r) => r.status !== 'removed').length; + + root.replaceChildren(); + + const header = document.createElement('header'); + header.className = 'lpb-header'; + + const titleRow = document.createElement('div'); + titleRow.className = 'lpb-title-row'; + const h1 = document.createElement('h1'); + h1.textContent = 'Landing Page Builder Log'; + titleRow.appendChild(h1); + + const actions = document.createElement('div'); + actions.className = 'lpb-actions'; + const csvBtn = document.createElement('button'); + csvBtn.type = 'button'; + csvBtn.className = 'lpb-btn lpb-btn-secondary'; + csvBtn.dataset.action = 'download-csv'; + csvBtn.title = 'Exports all pages (all tabs) as CSV'; + csvBtn.textContent = 'Download CSV'; + csvBtn.disabled = state.rows.length === 0; + actions.appendChild(csvBtn); + const rebuildBtn = document.createElement('button'); + rebuildBtn.className = 'lpb-btn lpb-btn-primary'; + rebuildBtn.dataset.action = 'rebuild'; + rebuildBtn.textContent = state.scanning ? 'Scanning...' : 'Rebuild From Scan'; + rebuildBtn.disabled = state.scanning || !state.signedIn; + actions.appendChild(rebuildBtn); + titleRow.appendChild(actions); + header.appendChild(titleRow); + + const subtitle = document.createElement('p'); + subtitle.className = 'lpb-subtitle'; + subtitle.innerHTML = 'Pages built with the landing page builder, logged in real time on Save & Preview and reconciled via a crawl of /resources.'; + header.appendChild(subtitle); + + const published = state.rows.filter((r) => r.status !== 'removed' && r.publishState === 'published').length; + const unpublished = state.rows.filter((r) => r.status !== 'removed' && r.publishState !== 'published').length; + const deleted = total - active; + + const stats = document.createElement('div'); + stats.className = 'lpb-stats'; + appendStatChip(stats, published, 'published', false); + appendStatChip(stats, unpublished, 'unpublished', true); + appendStatChip(stats, deleted, 'deleted', true); + appendStatChip(stats, total, 'total', true); + header.appendChild(stats); + + const reconcileEl = createReconcileEl(state.rows, active, state.lastRebuildAt); + if (reconcileEl) header.appendChild(reconcileEl); + + if (!state.signedIn) { + const warn = document.createElement('div'); + warn.className = 'lpb-warning'; + warn.textContent = 'Sign in to run a scan or see results.'; + header.appendChild(warn); + } else if (state.scanning) { + const progress = document.createElement('div'); + progress.className = 'lpb-progress'; + progress.textContent = state.progress || 'Scanning…'; + header.appendChild(progress); + } + + if (state.error) { + const err = document.createElement('div'); + err.className = 'lpb-error'; + err.setAttribute('role', 'alert'); + err.textContent = state.error; + header.appendChild(err); + } + + const filters = document.createElement('div'); + filters.className = 'lpb-filters'; + filters.setAttribute('role', 'tablist'); + ['published', 'unpublished', 'deleted', 'all'].forEach((f) => { + const btn = document.createElement('button'); + btn.setAttribute('role', 'tab'); + btn.setAttribute('aria-selected', state.filter === f ? 'true' : 'false'); + btn.className = `lpb-filter${state.filter === f ? ' is-active' : ''}`; + btn.dataset.filter = f; + btn.textContent = `${f[0].toUpperCase()}${f.slice(1)}`; + filters.appendChild(btn); + }); + header.appendChild(filters); + + const section = document.createElement('section'); + section.className = 'lpb-table-wrap'; + section.appendChild(createTableEl(rows)); + + root.append(header, section); + + bindEvents(); +} + +(async function init() { + const sdk = await DA_SDK.catch(() => null); + root = document.querySelector('.lpb-log-app'); + if (!root) return; + root.removeAttribute('aria-busy'); + + if (!sdk?.context) { + state.error = 'Missing DA context. Open this tool from https://da.live/app/…'; + render(); + return; + } + + state.signedIn = !!sdk.token; + state.lastRebuildAt = readLastRebuildFromStorage(); + await loadLog(); +}()); diff --git a/tools/sidekick/config.json b/tools/sidekick/config.json index 5e2cd4dd..0d337874 100644 --- a/tools/sidekick/config.json +++ b/tools/sidekick/config.json @@ -68,6 +68,11 @@ "description": "Admin tools for Milo Graybox", "image": "https://publish-p133406-e1301188.adobeaemcloud.com/content/dam/milo/app-icons/graybox.jpg", "url": "https://da.live/app/adobecom/milo/tools/graybox" + }, + { + "title": "Landing Page Log", + "description": "Audit of pages built with the Landing Page Builder.", + "url": "https://da.live/app/adobecom/da-bacom/tools/lpb-log/lpb-log" } ] }