From 79571744c387d65930fed19e6171138eb3558833 Mon Sep 17 00:00:00 2001 From: somarc <8710267+somarc@users.noreply.github.com> Date: Wed, 13 May 2026 13:15:17 -0400 Subject: [PATCH 1/2] fix(page-status): correctly classify BYOM pages when sourceLastModified is null/absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #340. Pages without a source date (e.g. BYOM) were always shown as "No source" (negative) regardless of their preview/publish state. The fix extracts the classification logic into a pure helper (status.js) that falls through to preview/publish sequencing when no edit date is present — null and undefined are both treated as absent via the `?? NaN` guard. Co-Authored-By: Claude Sonnet 4.6 --- tests/tools/page-status/status.test.js | 76 ++++++++++++++++++++++++++ tools/page-status/scripts.js | 30 ++-------- tools/page-status/status.js | 39 +++++++++++++ 3 files changed, 119 insertions(+), 26 deletions(-) create mode 100644 tests/tools/page-status/status.test.js create mode 100644 tools/page-status/status.js diff --git a/tests/tools/page-status/status.test.js b/tests/tools/page-status/status.test.js new file mode 100644 index 00000000..7d8e7e73 --- /dev/null +++ b/tests/tools/page-status/status.test.js @@ -0,0 +1,76 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import classifySequenceStatus from '../../../tools/page-status/status.js'; + +const EDIT = '2024-01-01T00:00:00.000Z'; +const PREVIEW = '2024-01-02T00:00:00.000Z'; +const PUBLISH = '2024-01-03T00:00:00.000Z'; + +describe('classifySequenceStatus — with source date', () => { + it('returns Not previewed when only edit exists', () => { + const { label, positive } = classifySequenceStatus(EDIT, undefined, undefined); + assert.equal(label, 'Not previewed'); + assert.equal(positive, true); + }); + + it('returns Not published when edit and preview exist in sequence', () => { + const { label, positive } = classifySequenceStatus(EDIT, PREVIEW, undefined); + assert.equal(label, 'Not published'); + assert.equal(positive, true); + }); + + it('returns Pending changes when edit is newer than preview (unpublished edit)', () => { + const { label, positive } = classifySequenceStatus(PUBLISH, EDIT, undefined); + assert.equal(label, 'Pending changes'); + assert.equal(positive, true); + }); + + it('returns Current when edit → preview → publish are in sequence', () => { + const { label, positive } = classifySequenceStatus(EDIT, PREVIEW, PUBLISH); + assert.equal(label, 'Current'); + assert.equal(positive, true); + }); + + it('returns Pending changes when preview is newer than publish', () => { + const { label, positive } = classifySequenceStatus(EDIT, PUBLISH, PREVIEW); + assert.equal(label, 'Pending changes'); + assert.equal(positive, true); + }); +}); + +describe('classifySequenceStatus — no source (BYOM, issue #340)', () => { + // Both undefined (absent field) and null (explicit API value) must be treated as no source. + [undefined, null].forEach((noSource) => { + const label = noSource === null ? 'null' : 'undefined'; + + it(`returns No source (negative) when nothing exists [edit=${label}]`, () => { + const result = classifySequenceStatus(noSource, undefined, undefined); + assert.equal(result.label, 'No source'); + assert.equal(result.positive, false); + }); + + it(`returns Not published (positive) when only preview exists [edit=${label}]`, () => { + const result = classifySequenceStatus(noSource, PREVIEW, undefined); + assert.equal(result.label, 'Not published'); + assert.equal(result.positive, true); + }); + + it(`returns Current (positive) when only publish exists [edit=${label}]`, () => { + const result = classifySequenceStatus(noSource, undefined, PUBLISH); + assert.equal(result.label, 'Current'); + assert.equal(result.positive, true); + }); + + it(`returns Current (positive) when preview and publish are in sequence [edit=${label}]`, () => { + const result = classifySequenceStatus(noSource, PREVIEW, PUBLISH); + assert.equal(result.label, 'Current'); + assert.equal(result.positive, true); + }); + + it(`returns Pending changes (positive) when preview is newer than publish [edit=${label}]`, () => { + const result = classifySequenceStatus(noSource, PUBLISH, PREVIEW); + assert.equal(result.label, 'Pending changes'); + assert.equal(result.positive, true); + }); + }); +}); diff --git a/tools/page-status/scripts.js b/tools/page-status/scripts.js index 6df71010..3dd70548 100644 --- a/tools/page-status/scripts.js +++ b/tools/page-status/scripts.js @@ -3,6 +3,7 @@ import { decorateIcons } from '../../scripts/aem.js'; import { initConfigField, updateConfig } from '../../utils/config/config.js'; import { ensureLogin } from '../../blocks/profile/profile.js'; import loadingMessages from './loading-messages.js'; +import classifySequenceStatus from './status.js'; const FORM = document.getElementById('status-form'); const TABLE = document.querySelector('table'); @@ -319,34 +320,11 @@ function buildLink(text, url, path) { * @returns {HTMLSpanElement} Status light element indicating status and sequence. */ function buildSequenceStatus(edit, preview, publish) { - // check if a date is valid - const date = (d) => !Number.isNaN(d.getTime()); - const editDate = new Date(edit); - const previewDate = new Date(preview); - const publishDate = new Date(publish); - const inSequence = (editDate <= previewDate && previewDate <= publishDate); + const { label, positive } = classifySequenceStatus(edit, preview, publish); const span = document.createElement('span'); span.className = 'status-light'; - let status; - if (!date(editDate)) { - status = 'No source'; - span.classList.add('negative'); - } else if (date(editDate) && !date(previewDate) && !date(publishDate)) { - status = 'Not previewed'; - span.classList.add('positive'); - } else if ( - date(editDate) - && date(previewDate) - && !date(publishDate) - && editDate <= previewDate - ) { - status = 'Not published'; - span.classList.add('positive'); - } else { - status = inSequence ? 'Current' : 'Pending changes'; - span.classList.add('positive'); - } - span.textContent = status; + span.classList.add(positive ? 'positive' : 'negative'); + span.textContent = label; return span; } diff --git a/tools/page-status/status.js b/tools/page-status/status.js new file mode 100644 index 00000000..6f351796 --- /dev/null +++ b/tools/page-status/status.js @@ -0,0 +1,39 @@ +/** + * Classifies the sequence status for a resource based on edit, preview, and publish dates. + * Returns a label and whether the status is considered positive (vs. negative/actionable). + * @param {string|null|undefined} edit - Source/edit last-modified date. + * @param {string|null|undefined} preview - Preview last-modified date. + * @param {string|null|undefined} publish - Publish last-modified date. + * @returns {{ label: string, positive: boolean }} + */ +export default function classifySequenceStatus(edit, preview, publish) { + const valid = (d) => !Number.isNaN(d.getTime()); + // Treat null and undefined as absent — new Date(null) yields epoch which is a valid date. + const editDate = new Date(edit ?? NaN); + const previewDate = new Date(preview ?? NaN); + const publishDate = new Date(publish ?? NaN); + + if (!valid(editDate)) { + // No source (e.g. BYOM pages where sourceLastModified is null/absent from the admin API). + // Classify by preview/publish state rather than always returning a negative status. + if (!valid(previewDate) && !valid(publishDate)) { + return { label: 'No source', positive: false }; + } + if (valid(previewDate) && !valid(publishDate)) { + return { label: 'Not published', positive: true }; + } + if (!valid(previewDate) && valid(publishDate)) { + return { label: 'Current', positive: true }; + } + return { label: previewDate <= publishDate ? 'Current' : 'Pending changes', positive: true }; + } + + if (!valid(previewDate) && !valid(publishDate)) { + return { label: 'Not previewed', positive: true }; + } + if (valid(previewDate) && !valid(publishDate) && editDate <= previewDate) { + return { label: 'Not published', positive: true }; + } + const inSequence = editDate <= previewDate && previewDate <= publishDate; + return { label: inSequence ? 'Current' : 'Pending changes', positive: true }; +} From 38e48d5c5d203ec2342020129f2036aab0a7c989 Mon Sep 17 00:00:00 2001 From: somarc <8710267+somarc@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:17:30 -0400 Subject: [PATCH 2/2] fix(page-status): preserve no-source warning for authored content --- tests/tools/page-status/status.test.js | 38 +++++++++++++++++--- tools/page-status/scripts.js | 50 ++++++++++++++++++++------ tools/page-status/status.js | 14 ++++++-- 3 files changed, 83 insertions(+), 19 deletions(-) diff --git a/tests/tools/page-status/status.test.js b/tests/tools/page-status/status.test.js index 7d8e7e73..614eb264 100644 --- a/tests/tools/page-status/status.test.js +++ b/tests/tools/page-status/status.test.js @@ -38,37 +38,65 @@ describe('classifySequenceStatus — with source date', () => { }); }); +describe('classifySequenceStatus — no source for authored content', () => { + [undefined, null, 'not-a-date'].forEach((noSource) => { + const label = noSource === null ? 'null' : String(noSource); + + it(`preserves No source warning when only preview exists [edit=${label}]`, () => { + const result = classifySequenceStatus(noSource, PREVIEW, undefined); + assert.equal(result.label, 'No source'); + assert.equal(result.positive, false); + }); + + it(`preserves No source warning when preview and publish exist [edit=${label}]`, () => { + const result = classifySequenceStatus(noSource, PREVIEW, PUBLISH); + assert.equal(result.label, 'No source'); + assert.equal(result.positive, false); + }); + }); +}); + describe('classifySequenceStatus — no source (BYOM, issue #340)', () => { // Both undefined (absent field) and null (explicit API value) must be treated as no source. [undefined, null].forEach((noSource) => { const label = noSource === null ? 'null' : 'undefined'; it(`returns No source (negative) when nothing exists [edit=${label}]`, () => { - const result = classifySequenceStatus(noSource, undefined, undefined); + const result = classifySequenceStatus(noSource, undefined, undefined, { + allowMissingSourceDate: true, + }); assert.equal(result.label, 'No source'); assert.equal(result.positive, false); }); it(`returns Not published (positive) when only preview exists [edit=${label}]`, () => { - const result = classifySequenceStatus(noSource, PREVIEW, undefined); + const result = classifySequenceStatus(noSource, PREVIEW, undefined, { + allowMissingSourceDate: true, + }); assert.equal(result.label, 'Not published'); assert.equal(result.positive, true); }); it(`returns Current (positive) when only publish exists [edit=${label}]`, () => { - const result = classifySequenceStatus(noSource, undefined, PUBLISH); + const result = classifySequenceStatus(noSource, undefined, PUBLISH, { + allowMissingSourceDate: true, + }); assert.equal(result.label, 'Current'); assert.equal(result.positive, true); }); it(`returns Current (positive) when preview and publish are in sequence [edit=${label}]`, () => { - const result = classifySequenceStatus(noSource, PREVIEW, PUBLISH); + const result = classifySequenceStatus(noSource, PREVIEW, PUBLISH, { + allowMissingSourceDate: true, + }); assert.equal(result.label, 'Current'); assert.equal(result.positive, true); }); it(`returns Pending changes (positive) when preview is newer than publish [edit=${label}]`, () => { - const result = classifySequenceStatus(noSource, PUBLISH, PREVIEW); + const result = classifySequenceStatus(noSource, PUBLISH, PREVIEW, { + allowMissingSourceDate: true, + }); assert.equal(result.label, 'Pending changes'); assert.equal(result.positive, true); }); diff --git a/tools/page-status/scripts.js b/tools/page-status/scripts.js index 3dd70548..8cfa079b 100644 --- a/tools/page-status/scripts.js +++ b/tools/page-status/scripts.js @@ -319,8 +319,8 @@ function buildLink(text, url, path) { * @param {string} publish - Publish date. * @returns {HTMLSpanElement} Status light element indicating status and sequence. */ -function buildSequenceStatus(edit, preview, publish) { - const { label, positive } = classifySequenceStatus(edit, preview, publish); +function buildSequenceStatus(edit, preview, publish, options) { + const { label, positive } = classifySequenceStatus(edit, preview, publish, options); const span = document.createElement('span'); span.className = 'status-light'; span.classList.add(positive ? 'positive' : 'negative'); @@ -328,6 +328,12 @@ function buildSequenceStatus(edit, preview, publish) { return span; } +function isBYOMContentSource(contentSource) { + const { type, url } = contentSource || {}; + if (type !== 'markup') return false; + return !url?.startsWith('https://content.da.live') && !url?.includes('adobeaemcloud'); +} + function buildRedirectIcon(redirectLocation) { if (!redirectLocation) return ''; @@ -365,7 +371,7 @@ function buildRedirectIcon(redirectLocation) { * @param {string} resource.path - The resource's path. * @returns {HTMLTableRowElement|null} `` element for resource, or `null` if no `path`. */ -function buildResource(resource, live, preview) { +function buildResource(resource, live, preview, options) { const { path, sourceLastModified, @@ -381,6 +387,7 @@ function buildResource(resource, live, preview) { sourceLastModified, previewLastModified, publishLastModified, + options, ); const cols = [ path, @@ -408,9 +415,9 @@ function buildResource(resource, live, preview) { * @param {string} live - Base URL for live links. * @param {string} preview - Base URL for preview links. */ -function displayResources(resources, live, preview) { +function displayResources(resources, live, preview, options) { resources.forEach((resource) => { - const row = buildResource(resource, live, preview); + const row = buildResource(resource, live, preview, options); if (row) RESULTS.append(row); }); } @@ -454,6 +461,21 @@ async function validateHosts(org, site) { return { live, preview }; } +async function fetchPageStatusOptions(org, site) { + let res; + try { + res = await fetch(`https://admin.hlx.page/config/${org}/sites/${site}.json`); + } catch { + return {}; + } + + if (!res.ok) return {}; + const config = await res.json(); + return { + allowMissingSourceDate: isBYOMContentSource(config.content?.source), + }; +} + /** * Fetches job URL for page status admin operation. * @param {string} org - Organization name. @@ -530,12 +552,12 @@ async function runJob(url, retry = 10000) { * @param {string} preview - Base URL for preview resources. * @returns {Promise<>} Promise that resolves once job has run and results are displayed. */ -async function runAndDisplayJob(jobUrl, live, preview) { +async function runAndDisplayJob(jobUrl, live, preview, options) { const paths = await runJob(jobUrl); if (!paths || paths.length === 0) { throw new Error('No page status data found.'); } - displayResources(paths, live, preview); + displayResources(paths, live, preview, options); updateTableDisplay('results'); enableActionButtons(); } @@ -582,11 +604,14 @@ async function runFromParams(search) { // initial setup setupJob(FORM, FORM.querySelector('button')); // fetch host config - const { live, preview } = await validateHosts(org, site); + const [{ live, preview }, options] = await Promise.all([ + validateHosts(org, site), + fetchPageStatusOptions(org, site), + ]); updateConfig(); // fetch page status and display results const jobUrl = `https://admin.hlx.page/job/${org}/${site}/main/status/${job}`; - await runAndDisplayJob(jobUrl, live, preview); + await runAndDisplayJob(jobUrl, live, preview, options); updateJobParam(job); } catch (error) { updateTableError('Job'); @@ -632,12 +657,15 @@ async function init() { setupJob(target, submitter); const { path } = data; // fetch host config - const { live, preview } = await validateHosts(org, site); + const [{ live, preview }, options] = await Promise.all([ + validateHosts(org, site), + fetchPageStatusOptions(org, site), + ]); updateConfig(); // fetch page status and display results const jobUrl = await fetchJobUrl(org, site, path); if (!jobUrl) throw new Error('Failed to create page status job.'); - await runAndDisplayJob(jobUrl, live, preview); + await runAndDisplayJob(jobUrl, live, preview, options); } catch (error) { updateTableError('Job'); removeJobParam(); diff --git a/tools/page-status/status.js b/tools/page-status/status.js index 6f351796..4b05be69 100644 --- a/tools/page-status/status.js +++ b/tools/page-status/status.js @@ -4,9 +4,13 @@ * @param {string|null|undefined} edit - Source/edit last-modified date. * @param {string|null|undefined} preview - Preview last-modified date. * @param {string|null|undefined} publish - Publish last-modified date. + * @param {Object} [options] - Classification options. + * @param {boolean} [options.allowMissingSourceDate=false] - Whether missing source dates + * are expected. * @returns {{ label: string, positive: boolean }} */ -export default function classifySequenceStatus(edit, preview, publish) { +export default function classifySequenceStatus(edit, preview, publish, options = {}) { + const { allowMissingSourceDate = false } = options; const valid = (d) => !Number.isNaN(d.getTime()); // Treat null and undefined as absent — new Date(null) yields epoch which is a valid date. const editDate = new Date(edit ?? NaN); @@ -14,8 +18,12 @@ export default function classifySequenceStatus(edit, preview, publish) { const publishDate = new Date(publish ?? NaN); if (!valid(editDate)) { - // No source (e.g. BYOM pages where sourceLastModified is null/absent from the admin API). - // Classify by preview/publish state rather than always returning a negative status. + if (!allowMissingSourceDate) { + return { label: 'No source', positive: false }; + } + + // BYOM pages have no sourceLastModified from the admin API. Classify by + // preview/publish state rather than always returning a negative status. if (!valid(previewDate) && !valid(publishDate)) { return { label: 'No source', positive: false }; }