diff --git a/src/main/content-da-live-auth.js b/src/main/content-da-live-auth.js new file mode 100644 index 0000000..fb86942 --- /dev/null +++ b/src/main/content-da-live-auth.js @@ -0,0 +1,130 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { isTokenExpired, loadStoredToken } from './da-auth.js'; + +/** Hostname for protected DA media served outside admin.da.live/source. */ +export const CONTENT_DA_LIVE_HOST = 'content.da.live'; + +/** webRequest filter for injecting IMS auth on content.da.live subresources. */ +export const CONTENT_DA_LIVE_URL_FILTER = [`*://${CONTENT_DA_LIVE_HOST}/*`]; + +/** Shared partition so preview webviews reuse one session with auth wiring. */ +export const PREVIEW_WEBVIEW_PARTITION = 'persist:aem-preview'; + +const TOKEN_CACHE_MS = 30_000; + +/** + * @param {string} url + * @returns {boolean} + */ +export function isContentDaLiveUrl(url) { + try { + return new URL(url).hostname === CONTENT_DA_LIVE_HOST; + } catch { + return false; + } +} + +/** + * @param {Record} headers + * @param {string} token + * @returns {Record} + */ +export function withDaBearerAuth(headers, token) { + return { + ...headers, + Authorization: `Bearer ${token}`, + }; +} + +/** + * Reads a stored IMS token without triggering browser login. + * + * @param {string} tokenPath + * @returns {Promise} + */ +export async function loadStoredDaBearerToken(tokenPath) { + const stored = await loadStoredToken(tokenPath); + if (stored?.access_token && !isTokenExpired(stored)) { + return stored.access_token; + } + return null; +} + +/** + * @param {string} tokenPath + * @returns {{ getToken: () => Promise, clearCache: () => void }} + */ +export function createDaBearerTokenResolver(tokenPath) { + /** @type {string|null} */ + let cachedToken = null; + let cachedAt = 0; + + return { + async getToken() { + if (cachedToken && Date.now() - cachedAt < TOKEN_CACHE_MS) { + return cachedToken; + } + cachedToken = await loadStoredDaBearerToken(tokenPath); + cachedAt = Date.now(); + return cachedToken; + }, + clearCache() { + cachedToken = null; + cachedAt = 0; + }, + }; +} + +/** + * Adds `Authorization: Bearer ` to requests for protected content.da.live + * assets (images in preview webviews and the document view). + * + * @param {import('electron').Session} ses + * @param {() => Promise} getBearerToken + */ +export function registerContentDaLiveAuth(ses, getBearerToken) { + ses.webRequest.onBeforeSendHeaders( + { urls: CONTENT_DA_LIVE_URL_FILTER }, + (details, callback) => { + getBearerToken() + .then((token) => { + if (!token) { + callback({ requestHeaders: details.requestHeaders }); + return; + } + callback({ + requestHeaders: withDaBearerAuth(details.requestHeaders, token), + }); + }) + .catch(() => { + callback({ requestHeaders: details.requestHeaders }); + }); + }, + ); +} + +/** + * @param {string} tokenPath + * @param {typeof import('electron').session} electronSession + * @returns {{ clearCache: () => void }} + */ +export function initContentDaLiveAuth(tokenPath, electronSession) { + const resolver = createDaBearerTokenResolver(tokenPath); + registerContentDaLiveAuth(electronSession.defaultSession, resolver.getToken); + registerContentDaLiveAuth( + electronSession.fromPartition(PREVIEW_WEBVIEW_PARTITION), + resolver.getToken, + ); + return { clearCache: resolver.clearCache }; +} diff --git a/src/main/document-view-diff.js b/src/main/document-view-diff.js new file mode 100644 index 0000000..e7c07c9 --- /dev/null +++ b/src/main/document-view-diff.js @@ -0,0 +1,418 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Diffs two document views (see `document-view-html.js`) into a track-changes + * model: a flat list of blocks and section breaks, each marked as added, + * removed, modified, or unchanged. Modified content carries inline + * ``/`` markup. + */ + +import { parseDocumentHtml } from './document-view-html.js'; +import { myersDiff } from './diff.js'; + +/** @typedef {import('./document-view-html.js').DocumentView} DocumentView */ +/** @typedef {'added' | 'removed' | 'modified' | 'unchanged'} DiffChange */ + +/** @typedef {{ kind: 'break', change: DiffChange }} DiffBreakItem */ +/** @typedef {{ kind: 'content', change: DiffChange, html: string }} DiffContentItem */ +/** @typedef {{ change?: DiffChange, cells: string[] }} DiffRow */ +/** @typedef {{ + * kind: 'table', + * change: DiffChange, + * name: string, + * rows: DiffRow[], + * colSpan: number, + * }} DiffTableItem */ +/** @typedef {DiffBreakItem | DiffContentItem | DiffTableItem} DiffItem */ +/** @typedef {{ changed: boolean, items: DiffItem[] }} DocumentDiffView */ + +const HTML_TOKEN = /<[^>]*>|[^<\s]+|\s+/g; + +/** + * @param {string} html + * @returns {string[]} + */ +function tokenizeHtml(html) { + return html.match(HTML_TOKEN) || []; +} + +/** + * @param {string} token + * @returns {boolean} + */ +function isTagToken(token) { + return token.startsWith('<'); +} + +/** + * @param {string} token + * @returns {boolean} + */ +function isImgToken(token) { + return /^]/i.test(token); +} + +/** + * Groups token edits into alternating equal / change segments. + * + * @param {ReturnType} edits + * @returns {Array<{ type: 'equal', tokens: string[] } + * | { type: 'change', del: string[], ins: string[] }>} + */ +function buildSegments(edits) { + const segments = []; + for (const edit of edits) { + const isEqual = edit.type === 'equal'; + let last = segments[segments.length - 1]; + if (!last || (last.type === 'equal') !== isEqual) { + last = isEqual + ? { type: 'equal', tokens: [] } + : { type: 'change', del: [], ins: [] }; + segments.push(last); + } + if (isEqual) { + last.tokens.push(edit.line); + } else if (edit.type === 'delete') { + last.del.push(edit.line); + } else { + last.ins.push(edit.line); + } + } + return segments; +} + +/** + * @param {string[]} tokens + * @returns {boolean} + */ +function hasWords(tokens) { + return tokens.some((token) => token.trim() && !isTagToken(token)); +} + +/** + * @param {{ type: string, del?: string[], ins?: string[] }} segment + * @returns {boolean} + */ +function isReplacement(segment) { + return segment.type === 'change' && hasWords(segment.del) && hasWords(segment.ins); +} + +/** Max words in an equal run that still gets folded into a rewrite. */ +const MERGEABLE_ANCHOR_WORDS = 2; + +/** + * @param {{ type: string, tokens?: string[] }} segment + * @returns {boolean} + */ +function isMergeableAnchor(segment) { + let words = 0; + for (const token of segment.tokens) { + if (isTagToken(token)) { + return false; + } + if (token.trim()) { + words += 1; + } + } + return words <= MERGEABLE_ANCHOR_WORDS; +} + +/** + * A rewritten sentence usually shares whitespace and stray stopwords with its + * replacement, which fragments the word diff into per-word del/ins pairs. + * Fold short equal runs between two replacements into one del + one ins so + * the whole rewrite reads as a single tracked change. + * + * @param {ReturnType} segments + * @returns {ReturnType} + */ +function coalesceRewrites(segments) { + const out = []; + for (const segment of segments) { + const anchor = out[out.length - 1]; + const prevChange = out[out.length - 2]; + if ( + isReplacement(segment) + && anchor?.type === 'equal' && isMergeableAnchor(anchor) + && prevChange && isReplacement(prevChange) + ) { + out.pop(); + prevChange.del.push(...anchor.tokens, ...segment.del); + prevChange.ins.push(...anchor.tokens, ...segment.ins); + } else { + out.push(segment); + } + } + return out; +} + +/** + * @param {string[]} tokens + * @param {'del' | 'ins'} tag + * @returns {string} + */ +function renderChangeSide(tokens, tag) { + const out = []; + let buf = []; + + const flushBuf = () => { + if (buf.length === 0) { + return; + } + const text = buf.join(''); + if (text.trim()) { + out.push(`<${tag} class="da-diff-${tag}">${text}`); + } else if (tag === 'ins') { + out.push(text); + } + buf = []; + }; + + for (const token of tokens) { + if (isTagToken(token)) { + flushBuf(); + if (tag === 'ins') { + out.push(token); + } else if (isImgToken(token)) { + out.push(`${token}`); + } + } else { + buf.push(token); + } + } + flushBuf(); + + return out.join(''); +} + +/** + * Word-level diff of two HTML fragments, producing markup with + * ``/`` wrappers around changed text runs. Tags are treated as + * atomic tokens: inserted tags are kept as-is, deleted tags are dropped + * (except images, which remain visible struck through) so the result stays + * well-formed. + * + * @param {string} oldHtml + * @param {string} newHtml + * @returns {string} + */ +export function inlineHtmlDiff(oldHtml, newHtml) { + const edits = myersDiff(tokenizeHtml(oldHtml), tokenizeHtml(newHtml)); + const segments = coalesceRewrites(buildSegments(edits)); + + const out = []; + for (const segment of segments) { + if (segment.type === 'equal') { + out.push(segment.tokens.join('')); + } else { + out.push(renderChangeSide(segment.del, 'del')); + out.push(renderChangeSide(segment.ins, 'ins')); + } + } + return out.join(''); +} + +/** + * @param {DocumentView} view + * @returns {Array} + */ +function flattenView(view) { + const items = []; + (view.sections || []).forEach((section, index) => { + if (index > 0) { + items.push({ kind: 'break' }); + } + items.push(...section.blocks); + }); + return items; +} + +/** + * @param {ReturnType[number]} item + * @returns {string} + */ +function itemKey(item) { + if (item.kind === 'break') { + return 'break'; + } + if (item.kind === 'table') { + return `table:${item.name}:${JSON.stringify(item.rows)}`; + } + return `content:${item.html}`; +} + +/** + * @param {ReturnType[number]} item + * @param {DiffChange} change + * @returns {DiffItem} + */ +function toDiffItem(item, change) { + if (item.kind === 'break') { + return { kind: 'break', change }; + } + if (item.kind === 'table') { + return { + kind: 'table', + change, + name: item.name, + rows: item.rows.map((row) => ({ cells: row.cells })), + colSpan: item.colSpan, + }; + } + return { kind: 'content', change, html: item.html }; +} + +/** + * A removed block can be paired with an added one into a single "modified" + * block when they are the same kind of thing. + * + * @param {ReturnType[number]} oldItem + * @param {ReturnType[number]} newItem + * @returns {boolean} + */ +function pairable(oldItem, newItem) { + if (oldItem.kind === 'table' && newItem.kind === 'table') { + return oldItem.name === newItem.name; + } + return oldItem.kind === 'content' && newItem.kind === 'content'; +} + +/** + * @param {import('./document-view-html.js').DocumentTableBlock} oldBlock + * @param {import('./document-view-html.js').DocumentTableBlock} newBlock + * @returns {DiffTableItem} + */ +function diffTableBlocks(oldBlock, newBlock) { + const edits = myersDiff( + oldBlock.rows.map((row) => JSON.stringify(row.cells)), + newBlock.rows.map((row) => JSON.stringify(row.cells)), + ); + + /** @type {DiffRow[]} */ + const rows = []; + let delRun = []; + let insRun = []; + + const flushRun = () => { + const count = Math.max(delRun.length, insRun.length); + for (let i = 0; i < count; i += 1) { + const oldRow = delRun[i]; + const newRow = insRun[i]; + if (oldRow && newRow) { + const cellCount = Math.max(oldRow.cells.length, newRow.cells.length); + const cells = []; + for (let c = 0; c < cellCount; c += 1) { + cells.push(inlineHtmlDiff(oldRow.cells[c] ?? '', newRow.cells[c] ?? '')); + } + rows.push({ change: 'modified', cells }); + } else if (oldRow) { + rows.push({ change: 'removed', cells: oldRow.cells }); + } else { + rows.push({ change: 'added', cells: newRow.cells }); + } + } + delRun = []; + insRun = []; + }; + + for (const edit of edits) { + if (edit.type === 'equal') { + flushRun(); + rows.push({ change: 'unchanged', cells: oldBlock.rows[edit.oldIdx].cells }); + } else if (edit.type === 'delete') { + delRun.push(oldBlock.rows[edit.oldIdx]); + } else { + insRun.push(newBlock.rows[edit.newIdx]); + } + } + flushRun(); + + return { + kind: 'table', + change: 'modified', + name: newBlock.name, + rows, + colSpan: Math.max(oldBlock.colSpan, newBlock.colSpan), + }; +} + +/** + * @param {DocumentView} oldView + * @param {DocumentView} newView + * @returns {DocumentDiffView} + */ +export function diffDocumentViews(oldView, newView) { + const oldItems = flattenView(oldView); + const newItems = flattenView(newView); + const edits = myersDiff(oldItems.map(itemKey), newItems.map(itemKey)); + + /** @type {DiffItem[]} */ + const items = []; + let delRun = []; + let insRun = []; + + const flushRun = () => { + const count = Math.max(delRun.length, insRun.length); + for (let i = 0; i < count; i += 1) { + const oldItem = delRun[i]; + const newItem = insRun[i]; + if (oldItem && newItem && pairable(oldItem, newItem)) { + if (oldItem.kind === 'table') { + items.push(diffTableBlocks(oldItem, newItem)); + } else { + items.push({ + kind: 'content', + change: 'modified', + html: inlineHtmlDiff(oldItem.html, newItem.html), + }); + } + } else { + if (oldItem) { + items.push(toDiffItem(oldItem, 'removed')); + } + if (newItem) { + items.push(toDiffItem(newItem, 'added')); + } + } + } + delRun = []; + insRun = []; + }; + + for (const edit of edits) { + if (edit.type === 'equal') { + flushRun(); + items.push(toDiffItem(oldItems[edit.oldIdx], 'unchanged')); + } else if (edit.type === 'delete') { + delRun.push(oldItems[edit.oldIdx]); + } else { + insRun.push(newItems[edit.newIdx]); + } + } + flushRun(); + + return { + changed: items.some((item) => item.change !== 'unchanged'), + items, + }; +} + +/** + * @param {string} oldHtml + * @param {string} newHtml + * @returns {DocumentDiffView} + */ +export function diffDocumentHtml(oldHtml, newHtml) { + return diffDocumentViews(parseDocumentHtml(oldHtml), parseDocumentHtml(newHtml)); +} diff --git a/src/main/document-view-html.js b/src/main/document-view-html.js new file mode 100644 index 0000000..9e88047 --- /dev/null +++ b/src/main/document-view-html.js @@ -0,0 +1,414 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Parses da.live / EDS content HTML into a document view model aligned with + * `@da-tools/da-parser` (`aem2doc`) and the da-live ProseMirror editor. + */ + +import { unified } from 'unified'; +import rehypeParse from 'rehype-parse'; +import { toHtml } from 'hast-util-to-html'; + +/** @typedef {import('hast').Root} HastRoot */ +/** @typedef {import('hast').Element} HastElement */ +/** @typedef {import('hast').Nodes} HastNodes */ + +/** @typedef {{ cells: string[] }} DocumentRow */ +/** @typedef {{ + * kind: 'table', + * name: string, + * rows: DocumentRow[], + * colSpan: number, + * }} DocumentTableBlock */ +/** @typedef {{ kind: 'content', html: string }} DocumentContentBlock */ +/** @typedef {DocumentTableBlock | DocumentContentBlock} DocumentBlock */ +/** @typedef {{ blocks: DocumentBlock[] }} DocumentSection */ +/** @typedef {{ sections: DocumentSection[] }} DocumentView */ + +const REHYPE_PARSE = { fragment: true }; + +/** + * @param {HastNodes} node + * @returns {string} + */ +function textContent(node) { + if (node.type === 'text') { + return node.value; + } + if ('children' in node && Array.isArray(node.children)) { + return node.children.map(textContent).join(''); + } + return ''; +} + +/** + * @param {HastElement} node + * @returns {string[]} + */ +function getClassList(node) { + const cn = node.properties?.className; + if (Array.isArray(cn)) { + return cn.filter(Boolean); + } + if (typeof cn === 'string') { + return cn.split(/\s+/).filter(Boolean); + } + return []; +} + +/** + * @param {HastElement} node + * @param {string} className + * @returns {boolean} + */ +function hasClass(node, className) { + return getClassList(node).includes(className); +} + +/** + * @param {string[]} classes + * @returns {string} + */ +function formatBlockHeaderName(classes) { + if (classes.length === 0) { + return 'default'; + } + const [name, ...options] = classes; + if (options.length === 0) { + return name; + } + return `${name} (${options.join(', ')})`; +} + +/** + * @param {HastElement} row + * @returns {HastElement[]} + */ +function rowCells(row) { + if (row.type !== 'element' || row.tagName !== 'div' || !row.children) { + return []; + } + return row.children.filter((c) => c.type === 'element' && c.tagName === 'div'); +} + +/** + * @param {HastElement} cell + * @returns {string} + */ +function cellHtml(cell) { + return toHtml(cell).replace(/^]*>/, '').replace(/<\/div>$/, ''); +} + +/** + * @param {HastElement[]} rowDivs + * @returns {DocumentRow[]} + */ +function rowsFromDivRows(rowDivs) { + /** @type {DocumentRow[]} */ + const rows = []; + for (const row of rowDivs) { + if (row.type !== 'element') { + // eslint-disable-next-line no-continue + continue; + } + const cells = rowCells(row); + if (cells.length >= 2) { + rows.push({ cells: cells.map(cellHtml) }); + } else if (cells.length === 1) { + rows.push({ cells: [cellHtml(cells[0])] }); + } else { + rows.push({ cells: [toHtml(row)] }); + } + } + return rows; +} + +/** + * @param {HastElement} blockEl + * @returns {number} + */ +function maxRowColumns(blockEl) { + const rowDivs = (blockEl.children || []).filter( + (c) => c.type === 'element' && c.tagName === 'div', + ); + return rowDivs.reduce((max, row) => { + if (row.type !== 'element') { + return max; + } + return Math.max(max, rowCells(row).length); + }, 1); +} + +/** + * @param {HastElement} node + * @returns {HastElement|null} + */ +function findMetadataRoot(node) { + if (hasClass(node, 'metadata') || hasClass(node, 'section-metadata')) { + return node; + } + if (!node.children) { + return null; + } + for (const child of node.children) { + if (child.type === 'element') { + const found = findMetadataRoot(child); + if (found) { + return found; + } + } + } + return null; +} + +/** + * @param {HastElement} blockEl + * @returns {string|null} + */ +function tableHeaderName(blockEl) { + const firstRow = blockEl.children?.find((c) => c.type === 'element' && c.tagName === 'div'); + if (!firstRow || firstRow.type !== 'element') { + return null; + } + const cells = rowCells(firstRow); + if (cells.length !== 1) { + return null; + } + const name = textContent(cells[0]).trim(); + return name || null; +} + +/** + * @param {HastElement} blockEl + * @returns {DocumentTableBlock|null} + */ +function tableBlockFromDiv(blockEl) { + const metadataRoot = findMetadataRoot(blockEl); + const classes = getClassList(blockEl); + let headerName; + + if (metadataRoot) { + headerName = hasClass(metadataRoot, 'section-metadata') ? 'section metadata' : 'metadata'; + const rows = rowsFromDivRows( + (metadataRoot.children || []).filter((c) => c.type === 'element' && c.tagName === 'div'), + ); + if (rows.length === 0) { + return null; + } + const colSpan = rows.reduce((max, row) => Math.max(max, row.cells.length), 1); + return { + kind: 'table', + name: headerName, + rows, + colSpan, + }; + } + + if (classes.length > 0) { + headerName = formatBlockHeaderName(classes); + const rowDivs = (blockEl.children || []).filter( + (c) => c.type === 'element' && c.tagName === 'div', + ); + const rows = rowsFromDivRows(rowDivs); + if (rows.length === 0) { + return null; + } + return { + kind: 'table', + name: headerName, + rows, + colSpan: maxRowColumns(blockEl), + }; + } + + const header = tableHeaderName(blockEl); + if (!header) { + return null; + } + + const rowDivs = (blockEl.children || []).filter( + (c) => c.type === 'element' && c.tagName === 'div', + ); + const rows = rowsFromDivRows(rowDivs.slice(1)); + if (rows.length === 0) { + return null; + } + + return { + kind: 'table', + name: header, + rows, + colSpan: maxRowColumns(blockEl), + }; +} + +/** + * @param {HastNodes} node + * @returns {DocumentBlock|null} + */ +function blockFromNode(node) { + if (node.type !== 'element') { + return null; + } + + if (node.tagName === 'hr') { + return null; + } + + if (node.tagName === 'div') { + const tableBlock = tableBlockFromDiv(node); + if (tableBlock) { + return tableBlock; + } + const html = toHtml(node).trim(); + if (!html) { + return null; + } + return { kind: 'content', html }; + } + + return { kind: 'content', html: toHtml(node) }; +} + +/** + * @param {HastNodes[]} nodes + * @returns {DocumentBlock[]} + */ +function blocksFromNodes(nodes) { + /** @type {DocumentBlock[]} */ + const blocks = []; + for (const node of nodes) { + const block = blockFromNode(node); + if (block) { + blocks.push(block); + } + } + return blocks; +} + +/** + * @param {HastRoot | HastElement} root + * @returns {HastNodes[][]} + */ +function splitIntoSections(root) { + const children = root.children || []; + /** @type {HastNodes[][]} */ + const sections = []; + /** @type {HastNodes[]} */ + let current = []; + + for (const node of children) { + if (node.type === 'element' && node.tagName === 'hr') { + if (current.length > 0) { + sections.push(current); + current = []; + } + // eslint-disable-next-line no-continue + continue; + } + current.push(node); + } + + if (current.length > 0) { + sections.push(current); + } + + return sections.length > 0 ? sections : [[]]; +} + +/** + * Stored AEM pages wrap each section in a top-level `
` child `
`. + * + * @param {HastRoot} tree + * @returns {HastNodes[][]} + */ +function sectionsFromMain(tree) { + const main = tree.children.find( + (c) => c.type === 'element' && c.tagName === 'main', + ); + if (!main || main.type !== 'element') { + return splitIntoSections(tree); + } + + const hasHr = (main.children || []).some( + (c) => c.type === 'element' && c.tagName === 'hr', + ); + if (hasHr) { + return splitIntoSections(main); + } + + /** @type {HastNodes[][]} */ + const sections = []; + /** @type {HastNodes[]} */ + let loose = []; + + for (const child of main.children || []) { + if (child.type === 'element' && child.tagName === 'div') { + if (loose.length > 0) { + sections.push(loose); + loose = []; + } + sections.push(child.children || []); + } else { + loose.push(child); + } + } + + if (loose.length > 0) { + sections.push(loose); + } + + return sections.length > 0 ? sections : [[]]; +} + +/** + * @param {string} htmlFragment + * @returns {DocumentView} + */ +export function parseDocumentHtml(htmlFragment) { + const trimmed = htmlFragment.trim(); + if (!trimmed) { + return { sections: [] }; + } + + /** @type {HastRoot} */ + let tree; + try { + tree = unified().use(rehypeParse, REHYPE_PARSE).parse(trimmed); + } catch { + return { + sections: [{ + blocks: [{ kind: 'content', html: trimmed }], + }], + }; + } + + let sectionNodes = sectionsFromMain(tree); + + // Body fragments without
: sibling top-level divs are section wrappers. + if (!tree.children.some((c) => c.type === 'element' && c.tagName === 'main') + && !tree.children.some((c) => c.type === 'element' && c.tagName === 'hr') + && tree.children.filter((c) => c.type === 'element' && c.tagName === 'div').length > 1) { + sectionNodes = tree.children + .filter((c) => c.type === 'element' && c.tagName === 'div') + .map((div) => (div.type === 'element' ? div.children || [] : [])); + } + + const sections = sectionNodes.map((nodes) => ({ + blocks: blocksFromNodes(nodes), + })).filter((section) => section.blocks.length > 0); + + return { + sections: sections.length > 0 ? sections : [{ blocks: [] }], + }; +} diff --git a/src/main/index.js b/src/main/index.js index 4156e6d..3d67907 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -10,12 +10,12 @@ * governing permissions and limitations under the License. */ import { - app, BrowserWindow, dialog, ipcMain, shell, + app, BrowserWindow, dialog, ipcMain, session, shell, } from 'electron'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; -import { writeFile } from 'node:fs/promises'; +import { readFile, writeFile } from 'node:fs/promises'; import { createWindowOptions } from './window-options.js'; import { initAutoUpdater } from './updater.js'; import { screenshotFilename } from './dev-config.js'; @@ -43,13 +43,18 @@ import { } from './site-store.js'; import { loadSyncFolder, saveSyncFolder } from './sync-folder-store.js'; import { formatContentForDisplay } from './content-format.js'; +import { parseDocumentHtml } from './document-view-html.js'; +import { diffDocumentHtml } from './document-view-diff.js'; +import { + initContentDaLiveAuth, +} from './content-da-live-auth.js'; import { buildPreviewUrl, buildProxyPreviewUrl } from './preview-url.js'; import { startPreviewServer } from './preview-server.js'; import { createPreviewServerRegistry } from './preview-server-registry.js'; import { createHeadHtmlCache } from './head-html.js'; import { createMetadataJsonCache } from './metadata-json.js'; import { - runSync, syncRoot, checkSyncStatus, + runSync, syncRoot, syncPaths, checkSyncStatus, collectFolder, isBinaryExtension, checkPushStatus, runPush, computePushDiffs, checkLocalSyncBadges, checkPullStatus, runPull, runRevert, @@ -74,6 +79,8 @@ let mainWindow; let sitesCache = []; /** @type {ReturnType|null} */ let previewRegistry = null; +/** @type {{ clearCache: () => void }|null} */ +let contentDaLiveAuth = null; function userDataPath(name) { return join(app.getPath('userData'), name); @@ -347,10 +354,15 @@ ipcMain.handle('da:login', async () => { tokenPath: tokenPath(), openBrowser: (url) => shell.openExternal(url), }); + contentDaLiveAuth?.clearCache(); return getAuthStatus(tokenPath()); }); -ipcMain.handle('da:logout', async () => logout(tokenPath())); +ipcMain.handle('da:logout', async () => { + await logout(tokenPath()); + contentDaLiveAuth?.clearCache(); + return getAuthStatus(tokenPath()); +}); ipcMain.handle('preview:login', async (_event, { siteId }) => { const sites = await ensureSitesLoaded(); @@ -412,6 +424,60 @@ ipcMain.handle('da:get-source', async (_event, { siteId, daPath }) => { }); }); +ipcMain.handle('document:parse', async (_event, { html }) => parseDocumentHtml(html)); + +/** + * @param {string} path + * @returns {Promise} + */ +async function readTextFileIfExists(path) { + try { + return await readFile(path, 'utf8'); + } catch { + return null; + } +} + +const DOCUMENT_DIFF_EXTS = new Set(['html', 'htm']); + +// Track-changes diff between the synced original (remote snapshot under +// `.aem/`) and the local working copy. Returns null when there is nothing +// local to compare, so the renderer can fall back to the plain remote view. +ipcMain.handle('document:diff', async (_event, { siteId, destFolder, daPath }) => { + const sites = await ensureSitesLoaded(); + const site = findSite(sites, siteId); + if (!site) { + throw new Error('Site not found'); + } + if (!destFolder) { + return null; + } + const ext = daPath.split('.').pop().toLowerCase(); + if (!DOCUMENT_DIFF_EXTS.has(ext)) { + return null; + } + + const { workingPath, originalPath } = syncPaths(destFolder, site.org, site.repo, daPath); + const [working, original] = await Promise.all([ + readTextFileIfExists(workingPath), + readTextFileIfExists(originalPath), + ]); + if (working === null && original === null) { + return null; + } + + const diff = diffDocumentHtml(original ?? '', working ?? ''); + let status = 'modified'; + if (!diff.changed) { + status = 'unchanged'; + } else if (original === null) { + status = 'new'; + } else if (working === null) { + status = 'deleted'; + } + return { status, diff }; +}); + let syncAbortController = null; ipcMain.handle('sync:get-folder', async () => loadSyncFolder(syncFolderStorePath())); @@ -851,6 +917,8 @@ ipcMain.handle('dev:capture-screenshot', async (event) => { }); app.whenReady().then(async () => { + contentDaLiveAuth = initContentDaLiveAuth(tokenPath(), session); + previewRegistry = createPreviewServerRegistry({ startPreviewServer, createHeadHtmlCache, diff --git a/src/preload/index.cjs b/src/preload/index.cjs index ec73d78..d280574 100644 --- a/src/preload/index.cjs +++ b/src/preload/index.cjs @@ -31,7 +31,13 @@ contextBridge.exposeInMainWorld('aemDesktop', { logoutDa: () => ipcRenderer.invoke('da:logout'), listDa: (siteId, daPath) => ipcRenderer.invoke('da:list', { siteId, daPath }), getDaSource: (siteId, daPath) => ipcRenderer.invoke('da:get-source', { siteId, daPath }), + parseDocumentView: (html) => ipcRenderer.invoke('document:parse', { html }), + getDocumentDiff: (siteId, destFolder, daPath) => ipcRenderer.invoke( + 'document:diff', + { siteId, destFolder, daPath }, + ), buildPreviewUrl: (siteId, daPath) => ipcRenderer.invoke('preview:build-url', { siteId, daPath }), + previewWebviewPartition: 'persist:aem-preview', buildAemPreviewUrls: (siteId, daPaths) => ipcRenderer.invoke( 'preview:build-aem-urls', { siteId, daPaths }, diff --git a/src/renderer/document-view.css b/src/renderer/document-view.css new file mode 100644 index 0000000..1725271 --- /dev/null +++ b/src/renderer/document-view.css @@ -0,0 +1,207 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * Document view styling aligned with adobe/da-live blocks/edit/da-editor/da-editor.css + */ + +.document-view { + padding: 0; + overflow: auto; + background: var(--panel-bg); +} + +.da-document-view { + max-width: 52rem; + margin: 0 auto; + padding: 1.25rem 1.5rem 2rem; + background: #fff; + color: #464646; + font-family: var(--s2-font-family, 'Adobe Clean', sans-serif); + font-size: 0.875rem; + line-height: 1.5; + min-height: 100%; + box-sizing: border-box; +} + +.da-document-content { + margin: 0.35rem 0 0.75rem; +} + +.da-document-content :is(h1, h2, h3, h4, h5, h6) { + margin: 0.75rem 0 0.35rem; + color: #222; +} + +.da-document-content p { + margin: 0.35rem 0; +} + +.da-document-content a { + color: #0265dc; +} + +.da-document-view hr { + border: none; + border-radius: 1px; + border-top: 1px solid #b3b3b3; + position: relative; + overflow: visible; + margin: 1.25rem 0; +} + +.da-document-view hr::after { + left: 50%; + position: absolute; + content: 'Section Break'; + text-transform: uppercase; + background: #fff; + top: -10px; + font-weight: 700; + color: #b3b3b3; + padding: 0 6px; + font-size: 12px; + transform: translateX(-50%); +} + +.da-document-view .tableWrapper { + overflow-x: auto; + border: 2px solid #b1b1b1; + border-radius: 6px; + margin: 2px 0 0.75rem; +} + +.da-document-view table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + overflow: hidden; + border-style: hidden; +} + +.da-document-view tr:first-child td, +.da-document-view tr:first-child th { + background: #f1f1f1; + text-align: center; + font-weight: 700; + text-transform: lowercase; +} + +.da-document-view td, +.da-document-view th { + border: 2px solid #b1b1b1; + vertical-align: top; + min-width: 100px; + padding: 8px; + color: #464646; + box-sizing: border-box; +} + +.da-document-view a { + color: #0265dc; + word-break: break-all; +} + +.da-document-view img { + display: block; + max-width: 100%; + max-height: 14rem; + border-radius: 3px; +} + +.da-document-view img[href] { + border: #5faaf9 4px solid; + box-sizing: border-box; +} + +/* Track-changes diff (da-document-diff) */ + +.da-document-view ins.da-diff-ins { + background: #dafbe1; + color: #116329; + text-decoration: underline; + text-decoration-skip-ink: none; +} + +.da-document-view del.da-diff-del { + background: #ffebe9; + color: #cf222e; + text-decoration: line-through; +} + +.da-document-view .da-diff-added { + background: #f2fbf4; + box-shadow: inset 3px 0 0 #1a7f37; + color: #116329; + text-decoration: underline; + text-decoration-color: rgb(26 127 55 / 45%); + text-decoration-skip-ink: none; +} + +.da-document-view .da-diff-removed { + background: #fff5f5; + box-shadow: inset 3px 0 0 #cf222e; + color: #cf222e; + text-decoration: line-through; + text-decoration-color: rgb(207 34 46 / 60%); +} + +.da-document-view :is(.da-diff-added, .da-diff-removed) + :is(h1, h2, h3, h4, h5, h6, a, td) { + color: inherit; +} + +/* text-decoration does not propagate into tables — restate it on cells */ +.da-document-view .tableWrapper.da-diff-added td { + text-decoration: underline; + text-decoration-color: rgb(26 127 55 / 45%); + text-decoration-skip-ink: none; +} + +.da-document-view .tableWrapper.da-diff-removed td { + text-decoration: line-through; + text-decoration-color: rgb(207 34 46 / 60%); +} + +.da-document-view .tableWrapper.da-diff-added { + border-color: #1a7f37; +} + +.da-document-view .tableWrapper.da-diff-removed { + border-color: #cf222e; +} + +.da-document-view tr.da-diff-row-added td { + background: #f2fbf4; + color: #116329; +} + +.da-document-view tr.da-diff-row-removed td { + background: #fff5f5; + color: #cf222e; + text-decoration: line-through; + text-decoration-color: rgb(207 34 46 / 60%); +} + +.da-document-view :is(.da-diff-removed, tr.da-diff-row-removed) img, +.da-document-view del.da-diff-del img { + opacity: 0.45; +} + +.da-document-view hr.da-diff-break-added { + border-top-color: #1a7f37; +} + +.da-document-view hr.da-diff-break-added::after { + content: 'Section Break (added)'; + color: #1a7f37; +} + +.da-document-view hr.da-diff-break-removed { + border-top-color: #cf222e; + border-top-style: dashed; +} + +.da-document-view hr.da-diff-break-removed::after { + content: 'Section Break (removed)'; + color: #cf222e; + text-decoration: line-through; +} diff --git a/src/renderer/document-view.js b/src/renderer/document-view.js new file mode 100644 index 0000000..624d26e --- /dev/null +++ b/src/renderer/document-view.js @@ -0,0 +1,159 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * @typedef {{ cells: string[] }} DocumentRow + * @typedef {{ + * kind: 'table', + * name: string, + * rows: DocumentRow[], + * colSpan: number, + * }} DocumentTableBlock + * @typedef {{ kind: 'content', html: string }} DocumentContentBlock + * @typedef {DocumentTableBlock | DocumentContentBlock} DocumentBlock + * @typedef {{ blocks: DocumentBlock[] }} DocumentSection + * @typedef {{ sections: DocumentSection[] }} DocumentView + */ + +/** + * @param {DocumentTableBlock} block + * @returns {HTMLDivElement} + */ +function createTableWrapper(block) { + const wrapper = document.createElement('div'); + wrapper.className = 'tableWrapper'; + + const table = document.createElement('table'); + const tbody = document.createElement('tbody'); + + const headerRow = document.createElement('tr'); + const headerCell = document.createElement('td'); + if (block.colSpan > 1) { + headerCell.colSpan = block.colSpan; + } + headerCell.textContent = block.name; + headerRow.append(headerCell); + tbody.append(headerRow); + + for (const row of block.rows) { + const tr = document.createElement('tr'); + if (row.change && row.change !== 'unchanged') { + tr.classList.add(`da-diff-row-${row.change}`); + } + for (let i = 0; i < row.cells.length; i += 1) { + const td = document.createElement('td'); + td.innerHTML = row.cells[i]; + if (row.cells.length < block.colSpan && i === row.cells.length - 1) { + td.colSpan = block.colSpan - i; + } + tr.append(td); + } + tbody.append(tr); + } + + table.append(tbody); + wrapper.append(table); + return wrapper; +} + +/** + * @param {DocumentContentBlock} block + * @returns {HTMLDivElement} + */ +function createContentBlock(block) { + const el = document.createElement('div'); + el.className = 'da-document-content'; + el.innerHTML = block.html; + return el; +} + +/** + * Renders a track-changes document diff (see `document-view-diff.js` in the + * main process): blocks and section breaks marked added/removed/modified, + * with inline ``/`` markup inside modified content. + * + * @param {HTMLElement} container + * @param {{ changed: boolean, items: Array }|null} model + * @param {string} [emptyMessage] + */ +export function renderDocumentDiffView(container, model, emptyMessage = 'No document content.') { + container.replaceChildren(); + container.classList.add('document-view'); + + if (!model || model.items.length === 0) { + const p = document.createElement('p'); + p.className = 'placeholder'; + p.textContent = emptyMessage; + container.append(p); + return; + } + + const prose = document.createElement('div'); + prose.className = 'da-document-view da-document-diff'; + + for (const item of model.items) { + if (item.kind === 'break') { + const hr = document.createElement('hr'); + if (item.change !== 'unchanged') { + hr.classList.add(`da-diff-break-${item.change}`); + } + prose.append(hr); + } else { + const el = item.kind === 'table' + ? createTableWrapper(item) + : createContentBlock(item); + if (item.change !== 'unchanged') { + el.classList.add(`da-diff-${item.change}`); + } + prose.append(el); + } + } + + container.append(prose); +} + +/** + * @param {HTMLElement} container + * @param {DocumentView|null} model + * @param {string} [emptyMessage] + */ +export function renderDocumentView(container, model, emptyMessage = 'No document content.') { + container.replaceChildren(); + container.classList.add('document-view'); + + if (!model || model.sections.every((s) => s.blocks.length === 0)) { + const p = document.createElement('p'); + p.className = 'placeholder'; + p.textContent = emptyMessage; + container.append(p); + return; + } + + const prose = document.createElement('div'); + prose.className = 'da-document-view'; + + model.sections.forEach((section, sectionIndex) => { + if (sectionIndex > 0) { + prose.append(document.createElement('hr')); + } + + for (const block of section.blocks) { + if (block.kind === 'table') { + prose.append(createTableWrapper(block)); + } else { + prose.append(createContentBlock(block)); + } + } + }); + + container.append(prose); +} diff --git a/src/renderer/index.html b/src/renderer/index.html index ffc1fa4..e8985a4 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -11,6 +11,7 @@ + @@ -76,8 +77,51 @@

Sites

-
-

Select a file from the folder list.

+ +
+
+
+
+

Select a file from the folder list.

+
@@ -120,8 +164,50 @@

Sites

-
-

Select a file to see changes.

+ +
+
+
+
+

Select a file to see changes.

+
diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js index 1554ab0..685cdc9 100644 --- a/src/renderer/renderer.js +++ b/src/renderer/renderer.js @@ -30,6 +30,7 @@ import { renderReviewFileList, renderDiffView, wireReviewKeyboard, togglePathsCheckState, } from './review-view.js'; +import { renderDocumentView, renderDocumentDiffView } from './document-view.js'; const state = { view: 'home', @@ -71,7 +72,14 @@ const els = { authStatus: document.getElementById('auth-status'), signInBtn: document.getElementById('sign-in-btn'), signOutBtn: document.getElementById('sign-out-btn'), - contentBody: document.getElementById('content-body'), + contentToolbar: document.getElementById('content-toolbar'), + contentSegment: document.getElementById('content-segment'), + contentSegmentPreview: document.getElementById('content-segment-preview'), + contentSegmentCode: document.getElementById('content-segment-code'), + contentSegmentDocument: document.getElementById('content-segment-document'), + contentPreviewPane: document.getElementById('content-preview-pane'), + contentCodePane: document.getElementById('content-code-pane'), + contentDocumentPane: document.getElementById('content-document-pane'), syncModal: document.getElementById('sync-modal'), syncPickFolder: document.getElementById('sync-pick-folder'), syncFolderPath: document.getElementById('sync-folder-path'), @@ -115,7 +123,14 @@ const els = { pullProgressText: document.getElementById('pull-progress-text'), reviewView: document.getElementById('review-view'), reviewFileContainer: document.getElementById('review-file-container'), - reviewDiffBody: document.getElementById('review-diff-body'), + reviewContentToolbar: document.getElementById('review-content-toolbar'), + reviewSegment: document.getElementById('review-segment'), + reviewSegmentPreview: document.getElementById('review-segment-preview'), + reviewSegmentCode: document.getElementById('review-segment-code'), + reviewSegmentDocument: document.getElementById('review-segment-document'), + reviewPreviewPane: document.getElementById('review-preview-pane'), + reviewCodePane: document.getElementById('review-code-pane'), + reviewDocumentPane: document.getElementById('review-document-pane'), reviewCancel: document.getElementById('review-cancel'), reviewRevert: document.getElementById('review-revert'), reviewPush: document.getElementById('review-push'), @@ -201,7 +216,7 @@ async function enterBrowse(siteId) { state.activeSiteId = siteId; await window.aemDesktop.setActivePreviewSite(siteId); resetTree(); - renderContentPlaceholder('Select a file from the folder list.'); + resetBrowseContentView(); showView('browse'); renderSites(); await refreshTree(); @@ -335,46 +350,61 @@ function renderAuthStatus() { renderSites(); } -let previewWebview = null; -let previewOrigin = null; -let previewDevEnabled = null; -let previewDevToolsAutoOpened = false; -// Last successfully-requested preview, so we can reload it after an in-app -// sign-in, plus a guard so a 401 burst only prompts once. -let lastPreview = null; -let previewAuthPrompted = false; +/** @typedef {'preview' | 'code' | 'document'} ContentMode */ -function destroyPreviewWebview() { - if (previewWebview) { - previewWebview.remove(); - previewWebview = null; - previewOrigin = null; - previewDevToolsAutoOpened = false; - } -} +const CONTENT_MODES = ['document', 'preview', 'code']; +const CONTENT_MODE_STORAGE_KEY = 'contentViewMode'; -function renderContentPlaceholder(message) { - els.contentBody.classList.remove('is-preview'); - destroyPreviewWebview(); - els.contentBody.replaceChildren(); - const p = document.createElement('p'); - p.className = 'placeholder'; - p.textContent = message; - els.contentBody.append(p); +/** + * @returns {ContentMode} + */ +function loadStoredContentMode() { + try { + const stored = localStorage.getItem(CONTENT_MODE_STORAGE_KEY); + if (stored && CONTENT_MODES.includes(stored)) { + return stored; + } + } catch { + /* localStorage unavailable */ + } + return 'document'; } -function isAllowedPreviewNavigation(url) { - if (!previewOrigin) { - return false; - } +/** + * @param {ContentMode} mode + */ +function saveContentMode(mode) { try { - const target = new URL(url); - return target.origin === new URL(previewOrigin).origin; + localStorage.setItem(CONTENT_MODE_STORAGE_KEY, mode); } catch { - return false; + /* localStorage unavailable */ } } +/** + * @param {ContentMode} mode + * @param {{ codeEnabled?: boolean }} [options] + * @returns {ContentMode} + */ +function resolveContentMode(mode, { codeEnabled = true } = {}) { + if (mode === 'code' && !codeEnabled) { + const stored = loadStoredContentMode(); + return stored === 'code' ? 'document' : stored; + } + return mode; +} + +let previewDevEnabled = null; + +/** @type {ContentMode} */ +let browseContentMode = loadStoredContentMode(); +/** @type {{ daPath: string, isFolder: boolean }|null} */ +let browseOpenedItem = null; +let browseCodeAvailable = false; + +/** @type {ContentMode} */ +let reviewContentMode = loadStoredContentMode(); + async function previewDevMode() { if (previewDevEnabled === null) { previewDevEnabled = await window.aemDesktop.isDev(); @@ -382,130 +412,477 @@ async function previewDevMode() { return previewDevEnabled; } -function wirePreviewWebviewDevTools(webview) { - webview.addEventListener('dom-ready', () => { - previewDevMode().then((dev) => { - if (!dev || webview !== previewWebview || previewDevToolsAutoOpened) { - return; - } - webview.openDevTools({ mode: 'right' }); - previewDevToolsAutoOpened = true; - }); - }); +function setPanePlaceholder(pane, message) { + pane.classList.remove('is-preview'); + pane.replaceChildren(); + const p = document.createElement('p'); + p.className = 'placeholder'; + p.textContent = message; + pane.append(p); +} - webview.addEventListener('did-fail-load', (event) => { - if (event.isMainFrame) { - console.error( - `[preview] main frame failed: ${event.errorDescription} (${event.errorCode}) ${event.validatedURL}`, - ); - } else { - console.warn( - `[preview] subresource failed: ${event.errorDescription} (${event.errorCode}) ${event.validatedURL}`, - ); - } +/** + * @param {HTMLElement} segmentEl + * @param {HTMLButtonElement[]} buttons + * @param {ContentMode} mode + * @param {{ codeEnabled?: boolean }} [options] + * @returns {ContentMode} + */ +function setSegmentMode(segmentEl, buttons, mode, { codeEnabled = true } = {}) { + const activeMode = resolveContentMode(mode, { codeEnabled }); + + // DOM state update on the passed segment container. + // eslint-disable-next-line no-param-reassign -- dataset drives the sliding thumb position + segmentEl.dataset.active = activeMode; + + buttons.forEach((btn, index) => { + const btnMode = CONTENT_MODES[index]; + const isActive = btnMode === activeMode; + btn.classList.toggle('is-active', isActive); + btn.setAttribute('aria-selected', String(isActive)); }); - webview.addEventListener('console-message', (event) => { - const line = `[preview:${event.level}] ${event.message}`; - if (event.level >= 3) { - console.error(line); - } else if (event.level === 2) { - console.warn(line); + const codeBtn = buttons[CONTENT_MODES.indexOf('code')]; + if (codeBtn) { + if (codeEnabled) { + codeBtn.removeAttribute('disabled'); } else { - console.info(line); + codeBtn.setAttribute('disabled', ''); } - }); + } + + return activeMode; } -function openPreviewDevTools() { - if (previewWebview) { - previewWebview.openDevTools({ mode: 'right' }); - previewDevToolsAutoOpened = true; +/** + * @param {{ preview: HTMLElement, code: HTMLElement, document: HTMLElement }} panes + * @param {ContentMode} mode + */ +function setPaneActive(panes, mode) { + panes.preview.classList.toggle('is-active', mode === 'preview'); + panes.preview.classList.toggle('is-preview', mode === 'preview'); + panes.code.classList.toggle('is-active', mode === 'code'); + panes.document.classList.toggle('is-active', mode === 'document'); +} + +const browsePanes = () => ({ + preview: els.contentPreviewPane, + code: els.contentCodePane, + document: els.contentDocumentPane, +}); + +const reviewPanes = () => ({ + preview: els.reviewPreviewPane, + code: els.reviewCodePane, + document: els.reviewDocumentPane, +}); + +const browseSegmentButtons = () => [ + els.contentSegmentDocument, + els.contentSegmentPreview, + els.contentSegmentCode, +]; + +const reviewSegmentButtons = () => [ + els.reviewSegmentDocument, + els.reviewSegmentPreview, + els.reviewSegmentCode, +]; + +/** + * @param {HTMLElement} pane + * @returns {{ + * show: (opts: { url: string, previewOrigin: string }) => void, + * reload: () => void, + * openDevTools: () => void, + * destroy: () => void, + * getWebview: () => (HTMLElement | null), + * getLastPreview: () => ({ url: string, previewOrigin: string } | null), + * renderPlaceholder: (message: string) => void, + * renderSignIn: (onLogin: (button: HTMLButtonElement) => void) => void, + * }} + */ +function createPreviewController(pane) { + let webview = null; + let previewOrigin = null; + let devToolsAutoOpened = false; + /** @type {{ url: string, previewOrigin: string }|null} */ + let lastPreview = null; + + function destroy() { + if (webview) { + webview.remove(); + webview = null; + previewOrigin = null; + devToolsAutoOpened = false; + } } -} -function ensurePreviewWebview(origin) { - if (previewWebview && previewOrigin === origin) { - return previewWebview; + function isAllowedPreviewNavigation(url) { + if (!previewOrigin) { + return false; + } + try { + const target = new URL(url); + return target.origin === new URL(previewOrigin).origin; + } catch { + return false; + } } - destroyPreviewWebview(); - els.contentBody.replaceChildren(); + function wirePreviewWebviewDevTools(wv) { + wv.addEventListener('dom-ready', () => { + previewDevMode().then((dev) => { + if (!dev || wv !== webview || devToolsAutoOpened) { + return; + } + wv.openDevTools({ mode: 'right' }); + devToolsAutoOpened = true; + }); + }); - const webview = document.createElement('webview'); - webview.className = 'preview-webview'; - webview.setAttribute('allowpopups', 'false'); + wv.addEventListener('did-fail-load', (event) => { + if (event.isMainFrame) { + console.error( + `[preview] main frame failed: ${event.errorDescription} (${event.errorCode}) ${event.validatedURL}`, + ); + } else { + console.warn( + `[preview] subresource failed: ${event.errorDescription} (${event.errorCode}) ${event.validatedURL}`, + ); + } + }); - webview.addEventListener('will-navigate', (event) => { - if (!isAllowedPreviewNavigation(event.url)) { - event.preventDefault(); - window.aemDesktop.openExternal(event.url); + wv.addEventListener('console-message', (event) => { + const line = `[preview:${event.level}] ${event.message}`; + if (event.level >= 3) { + console.error(line); + } else if (event.level === 2) { + console.warn(line); + } else { + console.info(line); + } + }); + } + + function ensureWebview(origin) { + if (webview && previewOrigin === origin) { + return webview; } - }); - webview.addEventListener('new-window', (event) => { - event.preventDefault(); - window.aemDesktop.openExternal(event.url); - }); + destroy(); + pane.replaceChildren(); + pane.classList.add('is-preview'); - webview.addEventListener('did-start-loading', () => { - previewDevMode().then((dev) => { - if (dev) { - console.info(`[preview] loading ${webview.getURL()}`); + const wv = document.createElement('webview'); + wv.className = 'preview-webview'; + wv.setAttribute('allowpopups', 'false'); + wv.setAttribute('partition', window.aemDesktop.previewWebviewPartition); + + wv.addEventListener('will-navigate', (event) => { + if (!isAllowedPreviewNavigation(event.url)) { + event.preventDefault(); + window.aemDesktop.openExternal(event.url); } }); - }); - els.contentBody.append(webview); - previewWebview = webview; - previewOrigin = origin; + wv.addEventListener('new-window', (event) => { + event.preventDefault(); + window.aemDesktop.openExternal(event.url); + }); - previewDevMode().then((dev) => { - if (dev && webview === previewWebview) { - wirePreviewWebviewDevTools(webview); - } - }); + wv.addEventListener('did-start-loading', () => { + previewDevMode().then((dev) => { + if (dev) { + console.info(`[preview] loading ${wv.getURL()}`); + } + }); + }); + + pane.append(wv); + webview = wv; + previewOrigin = origin; - webview.addEventListener('did-finish-load', () => { previewDevMode().then((dev) => { - if (dev) { - console.info(`[preview] loaded ${webview.getURL()}`); + if (dev && wv === webview) { + wirePreviewWebviewDevTools(wv); } }); - checkPreviewAuthError(webview); + + wv.addEventListener('did-finish-load', () => { + previewDevMode().then((dev) => { + if (dev) { + console.info(`[preview] loaded ${wv.getURL()}`); + } + }); + checkPreviewAuthError(wv); + }); + + return wv; + } + + return { + show({ url, previewOrigin: origin }) { + lastPreview = { url, previewOrigin: origin }; + pane.classList.add('is-preview'); + const wv = ensureWebview(origin); + wv.src = url; + }, + reload() { + webview?.reloadIgnoringCache(); + }, + openDevTools() { + if (webview) { + webview.openDevTools({ mode: 'right' }); + devToolsAutoOpened = true; + } + }, + destroy, + getWebview: () => webview, + getLastPreview: () => lastPreview, + renderPlaceholder(message) { + pane.classList.remove('is-preview'); + destroy(); + setPanePlaceholder(pane, message); + }, + renderSignIn(onLogin) { + pane.classList.remove('is-preview'); + destroy(); + pane.replaceChildren(); + + const wrap = document.createElement('div'); + wrap.className = 'placeholder preview-signin'; + + const message = document.createElement('p'); + message.textContent = 'This site requires sign-in to preview protected content.'; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'btn primary'; + button.textContent = 'Sign in to preview'; + button.addEventListener('click', () => onLogin(button)); + + wrap.append(message, button); + pane.append(wrap); + }, + }; +} + +const browsePreview = createPreviewController(els.contentPreviewPane); +const reviewPreview = createPreviewController(els.reviewPreviewPane); + +let previewAuthPrompted = false; + +function resetBrowseContentView() { + browseContentMode = loadStoredContentMode(); + browseOpenedItem = null; + browseCodeAvailable = false; + browsePreview.destroy(); + hide(els.contentToolbar); + browseContentMode = setSegmentMode( + els.contentSegment, + browseSegmentButtons(), + browseContentMode, + { codeEnabled: false }, + ); + setPaneActive(browsePanes(), browseContentMode); + const placeholder = 'Select a file from the folder list.'; + els.contentPreviewPane.replaceChildren(); + els.contentCodePane.replaceChildren(); + els.contentDocumentPane.replaceChildren(); + if (browseContentMode === 'preview') { + setPanePlaceholder(els.contentPreviewPane, placeholder); + } else if (browseContentMode === 'document') { + setPanePlaceholder(els.contentDocumentPane, placeholder); + } else { + setPanePlaceholder(els.contentCodePane, placeholder); + } +} + +function syncBrowseContentView() { + if (browseOpenedItem) { + show(els.contentToolbar); + } else { + hide(els.contentToolbar); + } + + browseContentMode = setSegmentMode( + els.contentSegment, + browseSegmentButtons(), + browseContentMode, + { codeEnabled: browseCodeAvailable }, + ); + setPaneActive(browsePanes(), browseContentMode); +} + +function setBrowseContentMode(mode) { + if (mode === 'code' && !browseCodeAvailable) { + return; + } + browseContentMode = mode; + saveContentMode(mode); + syncBrowseContentView(); + if (!browseOpenedItem) { + return; + } + if (mode === 'code') { + loadBrowseCode(browseOpenedItem); + } else if (mode === 'document') { + loadBrowseDocument(browseOpenedItem); + } +} + +function updateBrowseCodeAvailability() { + browseCodeAvailable = Boolean(syncFolder); + browseContentMode = resolveContentMode(browseContentMode, { codeEnabled: browseCodeAvailable }); + syncBrowseContentView(); +} + +async function loadFileDiff(daPath) { + if (!syncFolder || !state.activeSiteId) { + return null; + } + + const pushStatus = await window.aemDesktop.checkPush({ + siteId: state.activeSiteId, + destFolder: syncFolder, }); - return webview; + const modified = pushStatus.modified.includes(daPath) ? [daPath] : []; + const localNew = pushStatus.localNew.includes(daPath) ? [daPath] : []; + const deleted = pushStatus.deleted.includes(daPath) ? [daPath] : []; + + if (modified.length + localNew.length + deleted.length === 0) { + return null; + } + + const diffs = await window.aemDesktop.getPushDiffs({ + siteId: state.activeSiteId, + destFolder: syncFolder, + modified, + localNew, + deleted, + }); + + return diffs[0] ?? null; } -function showPreview({ url, previewOrigin: origin }) { - els.contentBody.classList.add('is-preview'); - lastPreview = { url, previewOrigin: origin }; - previewAuthPrompted = false; +async function loadBrowseCode(item) { + setPanePlaceholder(els.contentCodePane, 'Loading changes…'); + try { + const file = await loadFileDiff(item.daPath); + if (!file) { + setPanePlaceholder(els.contentCodePane, 'No local changes for this file.'); + return; + } + renderDiffView(els.contentCodePane, file); + } catch (err) { + setPanePlaceholder(els.contentCodePane, err.message || 'Failed to load changes.'); + } +} + +async function loadDocumentViewForPath(daPath, pane) { + setPanePlaceholder(pane, 'Loading document…'); + try { + // Local changes render as a track-changes diff against the synced + // original; otherwise fall back to the plain remote document. + if (syncFolder) { + const localDiff = await window.aemDesktop.getDocumentDiff( + state.activeSiteId, + syncFolder, + daPath, + ); + if (localDiff && localDiff.status !== 'unchanged') { + renderDocumentDiffView(pane, localDiff.diff); + return; + } + } + const source = await window.aemDesktop.getDaSource(state.activeSiteId, daPath); + if (!source) { + setPanePlaceholder(pane, 'No source content.'); + return; + } + if (source.mode !== 'html') { + setPanePlaceholder(pane, 'Document view is only available for HTML files.'); + return; + } + const model = await window.aemDesktop.parseDocumentView(source.text); + renderDocumentView(pane, model); + } catch (err) { + setPanePlaceholder(pane, err.message || 'Failed to load document.'); + } +} + +async function loadBrowseDocument(item) { + await loadDocumentViewForPath(item.daPath, els.contentDocumentPane); +} - const webview = ensurePreviewWebview(origin); - webview.src = url; +function refreshBrowseOpenedFile() { + if (!browseOpenedItem) { + return; + } + updateBrowseCodeAvailability(); + if (browseContentMode === 'code') { + loadBrowseCode(browseOpenedItem); + } else if (browseContentMode === 'document') { + loadBrowseDocument(browseOpenedItem); + } } -function renderPreviewSignIn() { - els.contentBody.classList.remove('is-preview'); - destroyPreviewWebview(); - els.contentBody.replaceChildren(); +function syncReviewContentView() { + if (reviewFocusPath) { + show(els.reviewContentToolbar); + } else { + hide(els.reviewContentToolbar); + } - const wrap = document.createElement('div'); - wrap.className = 'placeholder preview-signin'; + reviewContentMode = setSegmentMode( + els.reviewSegment, + reviewSegmentButtons(), + reviewContentMode, + ); + setPaneActive(reviewPanes(), reviewContentMode); +} - const message = document.createElement('p'); - message.textContent = 'This site requires sign-in to preview protected content.'; +function setReviewContentMode(mode) { + reviewContentMode = mode; + saveContentMode(mode); + syncReviewContentView(); + if (!reviewFocusPath) { + return; + } + if (mode === 'preview') { + loadReviewPreview(reviewFocusPath); + } else if (mode === 'document') { + loadReviewDocument(reviewFocusPath); + } +} - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'btn primary'; - button.textContent = 'Sign in to preview'; - button.addEventListener('click', () => doPreviewLogin(button)); +async function loadReviewDocument(daPath) { + await loadDocumentViewForPath(daPath, els.reviewDocumentPane); +} - wrap.append(message, button); - els.contentBody.append(wrap); +async function loadReviewPreview(daPath) { + if (!state.activeSiteId) { + return; + } + try { + const preview = await window.aemDesktop.buildPreviewUrl(state.activeSiteId, daPath); + reviewPreview.show({ + url: preview.url, + previewOrigin: preview.previewOrigin, + }); + } catch (err) { + reviewPreview.renderPlaceholder(err.message || 'Failed to load preview.'); + } +} + +function openPreviewDevTools() { + if (state.view === 'review') { + reviewPreview.openDevTools(); + return; + } + browsePreview.openDevTools(); } async function doPreviewLogin(button) { @@ -517,8 +894,9 @@ async function doPreviewLogin(button) { btn.textContent = 'Signing in…'; try { const result = await window.aemDesktop.loginPreview(state.activeSiteId); + const lastPreview = browsePreview.getLastPreview(); if (result?.ok && lastPreview) { - showPreview(lastPreview); + browsePreview.show(lastPreview); return; } btn.textContent = result?.error ? 'Sign-in failed — try again' : 'Sign in to preview'; @@ -537,11 +915,11 @@ function handlePreviewAuthRequired({ previewUrl } = {}) { return; } previewAuthPrompted = true; - renderPreviewSignIn(); + browsePreview.renderSignIn(doPreviewLogin); } async function detectPreviewAuthStatus(webview) { - if (!webview || webview !== previewWebview) { + if (!webview || webview !== browsePreview.getWebview()) { return null; } try { @@ -702,9 +1080,7 @@ async function hardRefresh() { state.tree.cache = {}; state.tree.syncBadges = new Map(); - if (previewWebview) { - previewWebview.reloadIgnoringCache(); - } + browsePreview.reload(); for (const daPath of expanded) { await loadFolder(daPath); // eslint-disable-line no-await-in-loop @@ -838,16 +1214,27 @@ async function previewFile(item) { return; } + browseOpenedItem = item; state.tree.openedDaPath = item.daPath; + updateBrowseCodeAvailability(); + previewAuthPrompted = false; try { const preview = await window.aemDesktop.buildPreviewUrl(state.activeSiteId, item.daPath); - showPreview({ + browsePreview.show({ url: preview.url, previewOrigin: preview.previewOrigin, }); + syncBrowseContentView(); + if (browseContentMode === 'code') { + await loadBrowseCode(item); + } else if (browseContentMode === 'document') { + await loadBrowseDocument(item); + } } catch (err) { - renderContentPlaceholder(err.message || 'Failed to load preview'); + browseOpenedItem = null; + browsePreview.renderPlaceholder(err.message || 'Failed to load preview'); + syncBrowseContentView(); } } @@ -1153,6 +1540,7 @@ function mergeSyncBadges(badges) { } updatePullChangesFromBadges(); paintFileTree(); + refreshBrowseOpenedFile(); } function updatePullChangesFromBadges() { @@ -1686,8 +2074,13 @@ function paintReviewFileList() { function showReviewDiff(daPath) { const file = reviewDiffs.find((d) => d.daPath === daPath); if (file) { - renderDiffView(els.reviewDiffBody, file); + renderDiffView(els.reviewCodePane, file); + } + loadReviewPreview(daPath); + if (reviewContentMode === 'document') { + loadReviewDocument(daPath); } + syncReviewContentView(); } function focusReviewFile(daPath) { @@ -1768,11 +2161,19 @@ function updateReviewActionButtons({ forceDisabled = false } = {}) { } function renderReviewPlaceholder(message) { - els.reviewDiffBody.replaceChildren(); - const p = document.createElement('p'); - p.className = 'placeholder'; - p.textContent = message; - els.reviewDiffBody.append(p); + reviewPreview.destroy(); + reviewContentMode = loadStoredContentMode(); + els.reviewCodePane.replaceChildren(); + els.reviewPreviewPane.replaceChildren(); + els.reviewDocumentPane.replaceChildren(); + if (reviewContentMode === 'preview') { + setPanePlaceholder(els.reviewPreviewPane, message); + } else if (reviewContentMode === 'document') { + setPanePlaceholder(els.reviewDocumentPane, message); + } else { + setPanePlaceholder(els.reviewCodePane, message); + } + syncReviewContentView(); } async function loadReviewChanges() { @@ -1918,6 +2319,8 @@ async function openPushModal() { reviewAnchorPath = null; reviewFocusPath = null; reviewCheckedPaths = new Set(); + reviewContentMode = loadStoredContentMode(); + reviewPreview.destroy(); els.reviewPush.disabled = true; els.reviewRevert.disabled = true; els.reviewPush.textContent = 'Push changes'; @@ -1979,6 +2382,8 @@ function closeReviewView() { removeRevertProgressListener = null; } resetReviewProgressUi(); + reviewPreview.destroy(); + reviewContentMode = loadStoredContentMode(); reviewDiffs = []; reviewSelectedPaths = new Set(); reviewAnchorPath = null; @@ -2417,6 +2822,38 @@ function wireUi() { els.reviewCancel.addEventListener('click', handleReviewCancelClick); els.reviewCopyPreviewUrls.addEventListener('click', copyReviewPreviewUrls); els.reviewPreviewPublish.addEventListener('click', openHelix6Modal); + els.contentSegmentPreview.addEventListener('click', () => { + if (browseContentMode !== 'preview') { + setBrowseContentMode('preview'); + } + }); + els.contentSegmentCode.addEventListener('click', () => { + if (els.contentSegmentCode.disabled || browseContentMode === 'code') { + return; + } + setBrowseContentMode('code'); + }); + els.contentSegmentDocument.addEventListener('click', () => { + if (browseContentMode === 'document') { + return; + } + setBrowseContentMode('document'); + }); + els.reviewSegmentPreview.addEventListener('click', () => { + if (reviewContentMode !== 'preview') { + setReviewContentMode('preview'); + } + }); + els.reviewSegmentCode.addEventListener('click', () => { + if (reviewContentMode !== 'code') { + setReviewContentMode('code'); + } + }); + els.reviewSegmentDocument.addEventListener('click', () => { + if (reviewContentMode !== 'document') { + setReviewContentMode('document'); + } + }); els.helix6Start.addEventListener('click', startHelix6Bulk); els.helix6Cancel.addEventListener('click', handleHelix6CancelClick); els.helix6Modal.addEventListener('click', (event) => { @@ -2456,6 +2893,8 @@ async function init() { wireUi(); state.icons = await loadIcons(); await loadSyncFolderPreference(); + updateBrowseCodeAvailability(); + syncReviewContentView(); await refreshAuthStatus(); await loadSites(); window.aemDesktop.onPreviewAuthRequired(handlePreviewAuthRequired); diff --git a/src/renderer/review-view.css b/src/renderer/review-view.css index 3508c04..2ddc648 100644 --- a/src/renderer/review-view.css +++ b/src/renderer/review-view.css @@ -170,9 +170,7 @@ background: var(--panel-bg); } -.review-diff-body { - flex: 1; - overflow: auto; +.review-panes .content-pane:not(.is-preview) { padding: 0; } diff --git a/src/renderer/styles.css b/src/renderer/styles.css index d4a49fd..8a63253 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -410,13 +410,100 @@ body { background: var(--panel-bg); } -.content-body { +.content-toolbar { + flex-shrink: 0; + display: flex; + justify-content: center; + align-items: center; + padding: 0.65rem 1.25rem; + background: var(--panel-bg); + box-shadow: inset 0 -1px 0 0 var(--s2-gray-200); +} + +.segmented-control { + position: relative; + display: inline-grid; + grid-template-columns: repeat(3, 1fr); + width: min(100%, 24rem); + padding: 2px; + border-radius: 0.5rem; + background: var(--s2-gray-100); +} + +.segmented-control-thumb { + position: absolute; + top: 2px; + bottom: 2px; + left: 2px; + width: calc(33.333% - 1.34px); + border-radius: 0.375rem; + background: var(--s2-gray-300); + box-shadow: + 0 1px 2px rgb(0 0 0 / 45%), + inset 0 1px 0 rgb(255 255 255 / 6%); + transition: transform 0.2s ease; + pointer-events: none; +} + +.segmented-control[data-active="document"] .segmented-control-thumb { + transform: translateX(0%); +} + +.segmented-control[data-active="preview"] .segmented-control-thumb { + transform: translateX(100%); +} + +.segmented-control[data-active="code"] .segmented-control-thumb { + transform: translateX(200%); +} + +.segmented-control-btn { + position: relative; + z-index: 1; + padding: 0.35rem 1rem; + border: none; + background: transparent; + font: inherit; + font-size: 0.8125rem; + line-height: 1.2; + color: var(--s2-gray-600); + cursor: pointer; + border-radius: 0.375rem; +} + +.segmented-control-btn.is-active { + font-weight: 600; + color: var(--s2-gray-900); +} + +.segmented-control-btn:disabled { + opacity: 0.35; + cursor: default; +} + +.content-panes { flex: 1; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 1fr; + min-height: 0; +} + +.content-pane { + grid-area: 1 / 1; + min-height: 0; overflow: auto; padding: 1rem 1.25rem; + visibility: hidden; + pointer-events: none; +} + +.content-pane.is-active { + visibility: visible; + pointer-events: auto; } -.content-body.is-preview { +.content-pane.is-preview { padding: 0; overflow: hidden; display: flex; diff --git a/test/content-da-live-auth.test.js b/test/content-da-live-auth.test.js new file mode 100644 index 0000000..1f9a53e --- /dev/null +++ b/test/content-da-live-auth.test.js @@ -0,0 +1,73 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + CONTENT_DA_LIVE_HOST, + createDaBearerTokenResolver, + isContentDaLiveUrl, + loadStoredDaBearerToken, + withDaBearerAuth, +} from '../src/main/content-da-live-auth.js'; + +test('isContentDaLiveUrl matches content.da.live only', () => { + assert.equal(isContentDaLiveUrl('https://content.da.live/adobe/da-bacom/media/hero.png'), true); + assert.equal(isContentDaLiveUrl('https://admin.da.live/source/org/repo/hero.png'), false); + assert.equal(isContentDaLiveUrl('https://main--site--org.aem.page/hero.png'), false); + assert.equal(isContentDaLiveUrl('not-a-url'), false); +}); + +test('withDaBearerAuth sets Authorization header', () => { + const headers = withDaBearerAuth({ accept: 'image/*' }, 'abc123'); + assert.equal(headers.accept, 'image/*'); + assert.equal(headers.Authorization, 'Bearer abc123'); +}); + +test('loadStoredDaBearerToken returns a valid stored token', async () => { + const dir = await mkdtemp(join(tmpdir(), 'da-bearer-')); + const tokenPath = join(dir, '.da-token.json'); + await writeFile(tokenPath, `${JSON.stringify({ + access_token: 'stored-token', + expires_at: Date.now() + 3600_000, + })}\n`); + + const token = await loadStoredDaBearerToken(tokenPath); + assert.equal(token, 'stored-token'); +}); + +test('createDaBearerTokenResolver caches token reads', async () => { + const dir = await mkdtemp(join(tmpdir(), 'da-bearer-cache-')); + const tokenPath = join(dir, '.da-token.json'); + await writeFile(tokenPath, `${JSON.stringify({ + access_token: 'cached-token', + expires_at: Date.now() + 3600_000, + })}\n`); + + const resolver = createDaBearerTokenResolver(tokenPath); + assert.equal(await resolver.getToken(), 'cached-token'); + + await writeFile(tokenPath, `${JSON.stringify({ + access_token: 'rotated-token', + expires_at: Date.now() + 3600_000, + })}\n`); + assert.equal(await resolver.getToken(), 'cached-token'); + + resolver.clearCache(); + assert.equal(await resolver.getToken(), 'rotated-token'); +}); + +test('CONTENT_DA_LIVE_HOST is content.da.live', () => { + assert.equal(CONTENT_DA_LIVE_HOST, 'content.da.live'); +}); diff --git a/test/document-view-diff.test.js b/test/document-view-diff.test.js new file mode 100644 index 0000000..bf8552d --- /dev/null +++ b/test/document-view-diff.test.js @@ -0,0 +1,234 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { diffDocumentHtml, inlineHtmlDiff } from '../src/main/document-view-diff.js'; + +test('diffDocumentHtml reports identical documents as unchanged', () => { + const html = `
+

Title

+
+
Left
Right
+
+
`; + + const diff = diffDocumentHtml(html, html); + + assert.equal(diff.changed, false); + assert.ok(diff.items.length > 0); + assert.ok(diff.items.every((item) => item.change === 'unchanged')); +}); + +test('diffDocumentHtml marks edited text as modified with inline ins/del', () => { + const oldHtml = '

Hello old world

'; + const newHtml = '

Hello new world

'; + + const diff = diffDocumentHtml(oldHtml, newHtml); + + assert.equal(diff.changed, true); + const modified = diff.items.find((item) => item.change === 'modified'); + assert.equal(modified.kind, 'content'); + assert.match(modified.html, /old<\/del>/); + assert.match(modified.html, /new<\/ins>/); +}); + +test('diffDocumentHtml marks an added block', () => { + const oldHtml = '

Title

'; + const newHtml = `
+

Title

+
+
Left
Right
+
+
`; + + const diff = diffDocumentHtml(oldHtml, newHtml); + + assert.equal(diff.changed, true); + const added = diff.items.find((item) => item.change === 'added'); + assert.equal(added.kind, 'table'); + assert.equal(added.name, 'hero'); +}); + +test('diffDocumentHtml marks a removed block', () => { + const oldHtml = `
+

Title

+
+
Left
Right
+
+
`; + const newHtml = '

Title

'; + + const diff = diffDocumentHtml(oldHtml, newHtml); + + assert.equal(diff.changed, true); + const removed = diff.items.find((item) => item.change === 'removed'); + assert.equal(removed.kind, 'table'); + assert.equal(removed.name, 'hero'); +}); + +test('diffDocumentHtml marks an added section break', () => { + const oldHtml = '

One

Two

'; + const newHtml = '

One

\n
\n

Two

'; + + const diff = diffDocumentHtml(oldHtml, newHtml); + + assert.equal(diff.changed, true); + const breakItem = diff.items.find((item) => item.kind === 'break'); + assert.equal(breakItem.change, 'added'); +}); + +test('diffDocumentHtml marks a removed section break', () => { + const oldHtml = '

One

\n
\n

Two

'; + const newHtml = '

One

Two

'; + + const diff = diffDocumentHtml(oldHtml, newHtml); + + assert.equal(diff.changed, true); + const breakItem = diff.items.find((item) => item.kind === 'break'); + assert.equal(breakItem.change, 'removed'); +}); + +test('diffDocumentHtml keeps an unchanged section break unchanged', () => { + const oldHtml = '

One

\n
\n

Two

'; + const newHtml = '

One

\n
\n

Two changed

'; + + const diff = diffDocumentHtml(oldHtml, newHtml); + + const breakItem = diff.items.find((item) => item.kind === 'break'); + assert.equal(breakItem.change, 'unchanged'); +}); + +test('diffDocumentHtml diffs table cells row by row', () => { + const oldHtml = `
+
Title
Old body
+
Keep
Same
+
`; + const newHtml = `
+
Title
New body
+
Keep
Same
+
`; + + const diff = diffDocumentHtml(oldHtml, newHtml); + + const table = diff.items.find((item) => item.kind === 'table'); + assert.equal(table.change, 'modified'); + assert.equal(table.name, 'hero'); + assert.equal(table.rows[0].change, 'modified'); + assert.match(table.rows[0].cells[1], /Old<\/del>/); + assert.match(table.rows[0].cells[1], /New<\/ins>/); + assert.equal(table.rows[1].change, 'unchanged'); +}); + +test('diffDocumentHtml marks added and removed table rows', () => { + const oldHtml = `
+
One
1
+
Two
2
+
`; + const newHtml = `
+
One
1
+
Three
3
+
Two
2
+
`; + + const diff = diffDocumentHtml(oldHtml, newHtml); + + const table = diff.items.find((item) => item.kind === 'table'); + assert.equal(table.change, 'modified'); + const addedRow = table.rows.find((row) => row.change === 'added'); + assert.deepEqual(addedRow.cells, ['Three', '3']); +}); + +test('diffDocumentHtml treats a renamed block as removed plus added', () => { + const oldHtml = '
Body
'; + const newHtml = '
'; + + const diff = diffDocumentHtml(oldHtml, newHtml); + + const removed = diff.items.find((item) => item.change === 'removed'); + const added = diff.items.find((item) => item.change === 'added'); + assert.equal(removed.name, 'hero'); + assert.equal(added.name, 'banner'); +}); + +test('diffDocumentHtml marks everything added for a new document', () => { + const diff = diffDocumentHtml('', '

Title

'); + + assert.equal(diff.changed, true); + assert.ok(diff.items.length > 0); + assert.ok(diff.items.every((item) => item.change === 'added')); +}); + +test('diffDocumentHtml marks everything removed for a deleted document', () => { + const diff = diffDocumentHtml('

Title

', ''); + + assert.equal(diff.changed, true); + assert.ok(diff.items.length > 0); + assert.ok(diff.items.every((item) => item.change === 'removed')); +}); + +test('inlineHtmlDiff coalesces a rewritten sentence into one del and one ins', () => { + const oldHtml = '

Unbelievable but this seems to be an original, this must have a name

'; + const newHtml = '

An Independent Drinker original, a stripped-down two-ingredient sipper ' + + 'of whiskey and Aperol. Simple enough to make the same way.

'; + + const html = inlineHtmlDiff(oldHtml, newHtml); + + assert.equal((html.match(/([^]*?)<\/del>/)[1]; + const ins = html.match(/([^]*?)<\/ins>/)[1]; + assert.equal(del, 'Unbelievable but this seems to be an original, this must have a name'); + assert.equal( + ins, + 'An Independent Drinker original, a stripped-down two-ingredient sipper ' + + 'of whiskey and Aperol. Simple enough to make the same way.', + ); +}); + +test('inlineHtmlDiff keeps single-word edits minimal', () => { + const html = inlineHtmlDiff( + '

The quick brown fox jumps

', + '

The quick red fox jumps

', + ); + + assert.equal(html, '

The quick brown' + + 'red fox jumps

'); +}); + +test('inlineHtmlDiff does not strike anchors between pure insertions', () => { + const html = inlineHtmlDiff( + '

keep

', + '

added keep also

', + ); + + assert.ok(!html.includes(')keep|keep(<|$)/); +}); + +test('inlineHtmlDiff keeps deleted images visible inside del', () => { + const oldHtml = '

Text more

'; + const newHtml = '

Text more

'; + + const html = inlineHtmlDiff(oldHtml, newHtml); + + assert.match(html, /<\/del>/); +}); + +test('inlineHtmlDiff drops deleted structural tags to stay well-formed', () => { + const oldHtml = '

One

Two

'; + const newHtml = '

One Two

'; + + const html = inlineHtmlDiff(oldHtml, newHtml); + + assert.equal((html.match(/

/g) || []).length, 1); + assert.equal((html.match(/<\/p>/g) || []).length, 1); +}); diff --git a/test/document-view-html.test.js b/test/document-view-html.test.js new file mode 100644 index 0000000..eccd996 --- /dev/null +++ b/test/document-view-html.test.js @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseDocumentHtml } from '../src/main/document-view-html.js'; + +test('parseDocumentHtml extracts title, blocks, and section breaks', () => { + const html = `

+

IBA Collections

+
+ +
+
+ +
`; + + const doc = parseDocumentHtml(html); + + assert.equal(doc.sections.length, 2); + assert.equal(doc.sections[0].blocks.length, 2); + assert.equal(doc.sections[0].blocks[0].kind, 'content'); + assert.match(doc.sections[0].blocks[0].html, /IBA Collections/); + assert.equal(doc.sections[0].blocks[1].kind, 'table'); + assert.equal(doc.sections[0].blocks[1].name, 'widget'); + assert.match(doc.sections[0].blocks[1].rows[0].cells[0], /example\.com\/widget\.html/); + assert.equal(doc.sections[1].blocks[0].name, 'metadata'); + assert.equal(doc.sections[1].blocks[0].rows[0].cells[0], 'Image'); + assert.match(doc.sections[1].blocks[0].rows[0].cells[1], /image\.jpg/); +}); + +test('parseDocumentHtml handles class-named blocks like aem2doc', () => { + const html = `
+
Left
Right
+
`; + + const doc = parseDocumentHtml(html); + assert.equal(doc.sections[0].blocks[0].kind, 'table'); + assert.equal(doc.sections[0].blocks[0].name, 'columns (center, dark)'); + assert.equal(doc.sections[0].blocks[0].rows[0].cells[0], 'Left'); + assert.equal(doc.sections[0].blocks[0].rows[0].cells[1], 'Right'); +}); + +test('parseDocumentHtml splits main section wrappers', () => { + const html = `
+
+
+
Title
Hero body
+
+
+
+ +
+
`; + + const doc = parseDocumentHtml(html); + assert.equal(doc.sections.length, 2); + assert.equal(doc.sections[0].blocks[0].name, 'hero'); + assert.equal(doc.sections[1].blocks[0].name, 'section metadata'); +}); + +test('parseDocumentHtml renders default content outside tables', () => { + const html = '

Intro paragraph

'; + const doc = parseDocumentHtml(html); + assert.equal(doc.sections[0].blocks[0].kind, 'content'); + assert.match(doc.sections[0].blocks[0].html, /Intro paragraph/); +});