From 4d0ce84114f201f1d80830bbe5e03b147014202c Mon Sep 17 00:00:00 2001 From: Jackson Sandland Date: Tue, 12 May 2026 11:14:35 -0700 Subject: [PATCH 1/2] LPB: Track Landing Page Builder Pages with a Spreadsheet Log (#160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Landing Page Builder audit log Track every page built with the landing page builder in a shared DA sheet so campaign pages (URL, publisher, publish date, content type, LPB version) are auditable for PMs without manual bookkeeping. - tools/generator/lpb-log.js: shared module with appendLogRow (realtime upsert that preserves original publishedAt) and rebuildLog (uses DA's crawl utility to walk /resources, extracts the hidden page-builder marker table, merges with existing rows, and soft-deletes pages that no longer carry the marker) - tools/generator/landing-page.js: append a log row after a successful save + preview; publisher resolved via context.user then JWT fallback - tools/lpb-log/: standalone DA viewer app with sortable table, active/ removed/all tabs, and a Rebuild-from-scan button with live progress - Registered in sidekick config as "Landing Page Log" - 22 unit tests covering marker extraction, merge, soft-delete, and realtime upsert behavior Sheet location: /tools/page-builder/landing-page/data/lpb-log Made-with: Cursor * adds csv export * lpb-log: cache-bust viewer assets so CSV button ships to browsers ES module + CSS on aem.page are cached aggressively; query params on script/link force a fresh fetch after deploy. Slightly raise disabled button opacity so Download CSV stays visible when the list is empty. Made-with: Cursor * resolve unknown publisher in data * capture last published by * remove Status and move last seen to header * force browser cache bust * resolve publisher via admin.da.live source, versionlist, and Helix status * remove public json and open spreadsheet from top of app * improve logging * refactor to avoid use of custom escapeHTML util * add comments to catch blocks for session storage * add locale-aware scanning and address review findings - extract LOCALES to scripts/locales.js to avoid scripts.js DOM side effects in paths-config.js - wire getScanRoots() into scanResources for parallel multi-locale crawling - await logPublish() so lana catches internal rejections - fix subtitle copy: "on publish" → "on Save & Preview" - update crawl mock to filter by path and loosen rebuildLog test assertion * add publish state distinction and fix scan progress UX - add fetchPublishState via Helix live API to distinguish published vs preview-only pages - wire publishState into appendLogRow and rebuildLog - rename logPublish → logPreview to reflect actual action - update viewer tabs to Published / Unpublished / Deleted / All - add Publish State column, rename publishedAt label to First Previewed - replace per-file scan progress with per-root counter (Scanning root X of 93) * add authoring link to viewer and remove LPB version column - add Author link per row opening the DA editor for that document - remove LPB version from table and CSV — not actionable enough to surface * move authoring link to its own column in the viewer - Author column replaces inline link in Page URL cell - CSV export writes the full DA edit URL in the Author column * fix scan finding 0 pages and default to All tab - make scanResources callback synchronous so crawl utility doesn't silently drop async I/O (callbacks are fire-and-forget in da.live's crawl); collect HTML paths first, then fetch+filter in a second pass - change default viewer tab from Published to All so existing log data is visible immediately without requiring a rebuild * fix marker detection for DA authoring table format and throttle source fetches - extractMarker now handles both formats: Franklin-processed divs with CSS classes (generated by addHiddenTable) and DA's authoring table format where the block name appears as text in the first cell ("table (hide-block, page-builder)") - process HTML paths in throttle-sized chunks (default 10) instead of all at once to avoid overwhelming the DA source API and silently dropping pages - add console.log for pages where source is unavailable or no marker found - add test for DA authoring table format detection * show completed root path in scan progress - pass completedRoot from scanResources onProgress callback - viewer displays "Scanned /jp/resources (44 of 93 roots) — 28 pages found" so each locale root is visible as it completes * fix publish state check — use AEM live HEAD instead of Helix admin status API - admin.hlx.page/status CORS-blocks requests from branch preview origins; replace with a HEAD request to the public AEM live CDN which allows CORS from any origin and is semantically correct (200 = published, non-200 = not) - update test to stub globalThis.fetch and assert the aem.live URL is called * crawl locale roots sequentially to prevent ERR_INSUFFICIENT_RESOURCES parallel crawls each spawn many admin.da.live/list requests; with 93 roots running at once the browser runs out of connections — crawl one root at a time so at most throttle (10) list requests are in flight at any moment * fix publish state detection: use Helix admin API via daFetch, scoped to en-US /resources - Replace aem.live HEAD approach (soft-404 returns 200 for all pages) with daFetch to admin.hlx.page/status — works from da.live origin - Add fetchSourceDoc to fetch DA source HTML without a Helix fallback call, eliminating the CORS noise that appeared during scan - Revert scanResources to en-US /resources only — multi-locale crawl (93 roots, 32 995 pages) was completely untenable; locale support is a separate task - Remove [lpb-scan] no-marker console.log (printed for every non-LPB page) - Add [lpb-status] console.log so the full Helix status payload is visible while diagnosing published/unpublished classification - Update publishState test to use daFetch mock instead of globalThis.fetch stub * improve scan progress UX: per-chunk updates and animated ellipsis button - scanResources now fires onProgress after each source-fetch chunk so the UI updates throughout the slow fetch phase, not just once after the crawl - Viewer shows two-phase messages: "Crawled /resources — N pages to check" then "Checking: X / N — K LPB pages found" (updates every 10 pages) - Button text changes to "Scanning" with a CSS animated ellipsis (::after) so it never looks statically done while requests are still in flight * revert locales.js extraction — inline LOCALES back into scripts.js The locale data was split out to avoid scripts.js side effects when paths-config.js imported it for getScanRoots(). Since the scan is now scoped to en-US /resources only, getScanRoots() is dead code. Removing it eliminates the dependency, making locales.js unnecessary. * restore scripts.js to its pre-branch state * simplify scan UX: static Scanning... button, rename Checking to Scanning in progress * remove lpb-status console log and unnecessary preview POST after save * re-enable multi-locale scan across all 93 /resources roots scripts/locales.js exports LOCALE_PREFIXES (keys only, no side effects) so paths-config.js can build scan roots without importing scripts.js. scripts/scripts.js is unchanged from stage. * move CONFIG.locales to scripts/locales.js as the single source of truth Both scripts.js and paths-config.js (via getScanRoots) now import from locales.js, eliminating the risk of the two objects drifting out of sync. * live scan counter and spinner on progress bar Crawl phase now shows 'Scanning: root N of 93 — X pages indexed' so the counter increments visibly across all roots. Source check phase already shows 'Scanning: X / Y — N LPB pages found'. CSS spinner added to the progress bar so there is always a visual working indicator between updates. * remove spinner from progress bar * restore spinner on progress bar * show current locale root in scan progress * pipeline crawl and source-check phases for faster scan - crawl 10 roots at a time (was sequential) and immediately start source-checking discovered paths while the next batch crawls - source-check in chunks of 60 (was 10) for higher parallelism - viewer progress combines both phases into one line during overlap * reduce crawl batch to 3 to avoid ERR_INSUFFICIENT_RESOURCES 10 concurrent crawls * internal throttle of 10 = 100 concurrent DA list requests, which overflows the browser connection pool. 3 is the proven safe ceiling (30 concurrent). * restore locale root in crawl progress message * fix log gaps and CSV export: log on page success regardless of PDF, export all tabs with full URLs * split previewedAt and publishedAt into distinct fields sourced from Helix preview.lastModified → previewedAt, live.lastModified → publishedAt; rebuild no longer falls back to now so timestamps reflect actual Helix state; sort falls back from publishedAt to previewedAt so unpublished pages stay visible * use marker publishedBy as sole publisher source, drop last-modified-by machinery DA last-modified-by header reflects who last touched the doc, not who published via LPB. Remove getSourceWithMeta, lastModifiedByFromSourceResponse, and the versionlist/Helix fallback fetches that were adding latency to every page load. * never fall back to prev publisher on rescan; unknown if marker has no publishedBy prev?.publisher could carry a wrong value set by the old last-modified-by code, causing stale incorrect names to persist through every subsequent rescan. * restore DA last-modified-by as publisher fallback when marker has no valid publisher resolvePublisher was writing 'unknown' into the marker for all pages because IMS tokens are opaque and the DA SDK doesn't surface user context. Treat a marker publishedBy of 'unknown' (or absent) the same as missing and fall back to the x-da-last-modified-by header, which correctly reflects who saved the page via LPB. * fall back to DA versionlist for publisher when marker has none The marker's publishedBy is 'unknown' (resolvePublisher can't decode opaque IMS tokens) or absent (pages saved before April 24). Use the DA versionlist API as a fallback — it records the actual user per version — so the publisher column shows real emails on rebuild. Marker value still wins when valid. * fetch IMS profile when DA pre-seeds initIms with token only DA calls setImsDetails(token) before the tool loads, so initIms() returns { accessToken } with no email or profile. Fall back to window.adobeIMS.getProfile() which DA already initialized, so resolvePublisher gets a real email instead of decoding an opaque token and writing 'unknown' to the marker. * replace publisher with previewedBy + publishedBy sourced from Helix status API * filter nala URLs from log view; disable scan and warn when not signed in * pulse signed-out warning to draw attention * improve signed-out warning copy --- scripts/locales.js | 101 +++++ scripts/scripts.js | 98 +--- test/tools/generator/da-utils.test.html | 5 +- test/tools/generator/lpb-log.test.html | 566 ++++++++++++++++++++++++ test/tools/generator/mocks/tree.js | 20 + tools/generator/da-utils.js | 26 +- tools/generator/landing-page.js | 51 ++- tools/generator/lpb-log.js | 395 +++++++++++++++++ tools/generator/paths-config.js | 32 ++ tools/lpb-log/lpb-log.css | 324 ++++++++++++++ tools/lpb-log/lpb-log.html | 31 ++ tools/lpb-log/lpb-log.js | 506 +++++++++++++++++++++ tools/sidekick/config.json | 5 + 13 files changed, 2041 insertions(+), 119 deletions(-) create mode 100644 scripts/locales.js create mode 100644 test/tools/generator/lpb-log.test.html create mode 100644 test/tools/generator/mocks/tree.js create mode 100644 tools/generator/lpb-log.js create mode 100644 tools/lpb-log/lpb-log.css create mode 100644 tools/lpb-log/lpb-log.html create mode 100644 tools/lpb-log/lpb-log.js 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..74333f48 --- /dev/null +++ b/tools/generator/lpb-log.js @@ -0,0 +1,395 @@ +/* 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; + } +} + +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 (key.includes('adobelogin.com') || key.includes('adobe.com')) { + 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" } ] } From 513a7bd9e20ed32a85ecc6a2b00525f4dceb20a4 Mon Sep 17 00:00:00 2001 From: Jackson Sandland Date: Tue, 12 May 2026 13:24:59 -0700 Subject: [PATCH 2/2] fix CodeQL incomplete URL substring sanitization in JWT key check (#167) --- tools/generator/lpb-log.js | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tools/generator/lpb-log.js b/tools/generator/lpb-log.js index 74333f48..7ea7f268 100644 --- a/tools/generator/lpb-log.js +++ b/tools/generator/lpb-log.js @@ -87,6 +87,25 @@ export function decodeJwtPayloadSegment(segment) { } } +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); @@ -95,7 +114,7 @@ function imsPublisherFromPayload(payload) { if (payload.user_id) return String(payload.user_id); if (payload.sub) return String(payload.sub); for (const key of Object.keys(payload)) { - if (key.includes('adobelogin.com') || key.includes('adobe.com')) { + if (isAdobeNamespacedKey(key)) { const v = payload[key]; if (typeof v === 'string' && v.includes('@')) return v; if (v && typeof v === 'object') {